@zalom/plastic 2.0.0-alpha.17 → 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 (46) 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/hook-capture +4 -105
  6. package/scripts/lib/action_graph_shim.rb +277 -0
  7. package/scripts/lib/atomic_write.rb +31 -0
  8. package/scripts/lib/graph_edges.rb +121 -0
  9. package/scripts/lib/graph_file.rb +246 -0
  10. package/scripts/lib/guarded_append.rb +155 -0
  11. package/scripts/lib/installer_core.rb +32 -0
  12. package/scripts/lib/node_file.rb +214 -0
  13. package/scripts/lib/node_ids.rb +99 -0
  14. package/scripts/lib/node_ledger.rb +377 -0
  15. package/scripts/lib/node_packet.rb +873 -0
  16. package/scripts/lib/outcome_report.rb +440 -0
  17. package/scripts/lib/packet_wrapper.rb +132 -0
  18. package/scripts/lib/ready_set.rb +462 -0
  19. package/scripts/lib/release_guard.rb +16 -0
  20. package/scripts/lib/report_screen.rb +122 -12
  21. package/scripts/lib/roadmap_queue.rb +161 -3
  22. package/scripts/lib/roadmap_savepoint.rb +26 -5
  23. package/scripts/lib/savepoint.rb +123 -12
  24. package/scripts/lib/work_graph_validator.rb +201 -0
  25. package/scripts/node-packet +92 -0
  26. package/scripts/node-transition +291 -0
  27. package/scripts/outcome-report +74 -0
  28. package/scripts/ready-set +126 -0
  29. package/scripts/release-check +118 -0
  30. package/scripts/report-screen +8 -1
  31. package/scripts/roadmap-savepoint +7 -0
  32. package/scripts/validate-work-graph +39 -0
  33. package/skills/auto/SKILL.md +2 -3
  34. package/skills/auto/references/human-report-contract.md +3 -2
  35. package/skills/intent-continuing/references/boarding-matrix.md +1 -0
  36. package/skills/intent-ending/SKILL.md +30 -19
  37. package/skills/intent-executing/SKILL.md +1 -1
  38. package/skills/releasing/SKILL.md +39 -0
  39. package/skills/releasing/references/promotion-and-tagging.md +10 -6
  40. package/skills/releasing/references/release-lines.md +1 -1
  41. package/templates/graph.md +16 -0
  42. package/templates/node-decision.md +11 -0
  43. package/templates/node-research.md +11 -0
  44. package/templates/node-verify.md +13 -0
  45. package/templates/node-work.md +22 -0
  46. package/templates/outcome.md +8 -6
@@ -0,0 +1,873 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "yaml"
5
+ require "date"
6
+ require "digest"
7
+ require "fileutils"
8
+ require_relative "node_file"
9
+ require_relative "graph_file"
10
+ require_relative "node_ledger"
11
+ require_relative "savepoint"
12
+ require_relative "insights"
13
+ require_relative "packet_wrapper"
14
+ require_relative "atomic_write"
15
+ require_relative "arm"
16
+
17
+ # NodePacket (intent 338, G5): builds a node's whole input from disk, the
18
+ # five blocks 327 section 8 fixed - the node, the ledger, the record, the
19
+ # knowledge hop, and where to work. n2 (this half) is the readers: every one
20
+ # of them a keyword seam with a real default, so the suite never shells out
21
+ # to git, never reads the owner's real store, and never sets an environment
22
+ # variable (spec D16). n3 adds assembly, the budget, and the packet's
23
+ # identity on top of the same file.
24
+ module NodePacket
25
+ module_function
26
+
27
+ DEFAULT_BUDGET_TOKENS = 8000
28
+ DEFAULT_HOP_TOKENS = 2000
29
+ MAX_LANDED_COMMITS = 10
30
+ DECISIONS_KEEP = 5
31
+ INSIGHTS_KEEP = 3
32
+
33
+ # C7's "the executor stops without it" (spec D9), rendered whenever a
34
+ # packet carries no lease and whenever the worktree Arm.worktree_block
35
+ # reports is not provisioned.
36
+ STOP_DIRECTIVE = "STOP: no lease is recorded for this node. Do not edit files or run any command until a runner dispatches this node with a holder, an expiry and a model."
37
+
38
+ # Pinned so `packet=<sha>` is a function of the repo's history alone
39
+ # (post-execution review finding B4): unpinned, `git log --stat` varies
40
+ # with the terminal's COLUMNS (abbreviates paths, narrows the graph
41
+ # column), the caller's `color.ui` (ANSI escapes land in the ledger data
42
+ # block), and gitconfig's `format.pretty`/`log.date`/`log.showSignature`.
43
+ GIT_LOG_FIXED_ARGS = %w[-c color.ui=false -c log.showSignature=false --no-pager log --no-color --pretty=fuller
44
+ --stat=200,200].freeze
45
+ LANDED_COMMITS_MAX_BYTES = 8000
46
+
47
+ def git_log_command(repo_dir:, files:)
48
+ ["git", "-C", repo_dir.to_s, *GIT_LOG_FIXED_ARGS, "-n", MAX_LANDED_COMMITS.to_s, "--", *Array(files)]
49
+ end
50
+
51
+ # `COLUMNS` unset (Process.spawn/Open3 delete a var whose value is nil)
52
+ # rather than merely left alone, so an interactive caller's terminal width
53
+ # never reaches `git log --stat`'s column math.
54
+ def git_log_env
55
+ { "COLUMNS" => nil }
56
+ end
57
+
58
+ # `landed commits` is one of the three never-cut blocks (matrix 3.9), so an
59
+ # unbounded `git log --stat` (ten verbose commit messages, say) could route
60
+ # the whole packet straight to exit 4 with no cut able to help.
61
+ def truncate_landed_commits(out)
62
+ return out.to_s if out.to_s.bytesize <= LANDED_COMMITS_MAX_BYTES
63
+
64
+ "#{out.byteslice(0, LANDED_COMMITS_MAX_BYTES)}\n[landed commits truncated at #{LANDED_COMMITS_MAX_BYTES} bytes]"
65
+ end
66
+
67
+ DEFAULT_GIT_RUNNER = lambda do |repo_dir:, files:|
68
+ require "open3"
69
+ out, _err, status = Open3.capture3(git_log_env, *git_log_command(repo_dir: repo_dir, files: files))
70
+ status.success? ? truncate_landed_commits(out) : nil
71
+ end
72
+
73
+ PROJECT_LAYOUT_RE = %r{\A(.*)/projects/([^/]+)/store/[^/]+\z}.freeze
74
+
75
+ # --- paths ---------------------------------------------------------------
76
+
77
+ def savepoint_path(intent_dir)
78
+ File.join(intent_dir, "savepoint.md")
79
+ end
80
+
81
+ def graph_path(intent_dir)
82
+ File.join(intent_dir, "graph.md")
83
+ end
84
+
85
+ def find_node_path(intent_dir, node)
86
+ dir = File.join(intent_dir, "nodes")
87
+ exact = File.join(dir, "#{node}.md")
88
+ return exact if File.exist?(exact)
89
+
90
+ Dir.glob(File.join(dir, "#{node}--*.md")).sort.first
91
+ end
92
+
93
+ # --- block 1: the node (instruction) --------------------------------------
94
+
95
+ # {ok:, error_kind:, text:, errors:, kind:, files:, budget:}. error_kind is
96
+ # :unknown_node (no node file for this id, or the id is not declared in
97
+ # graph.md - the runner's usage-error bucket, matrix 2.2), :unparsable (the
98
+ # node file exists but NodeFile.parse rejects it, matrix 2.1), or
99
+ # :unreadable_graph (graph.md itself does not parse).
100
+ def node_block(intent_dir:, node:, node_reader: NodeFile.method(:parse), graph_reader: GraphFile.method(:parse))
101
+ node_path = find_node_path(intent_dir, node)
102
+ unless node_path
103
+ return failure_block(:unknown_node, ["no node file for #{node.inspect}"])
104
+ end
105
+
106
+ parsed = node_reader.call(node_path)
107
+ unless parsed[:ok]
108
+ return failure_block(:unparsable, parsed[:errors])
109
+ end
110
+
111
+ graph = graph_reader.call(graph_path(intent_dir))
112
+ unless graph[:ok]
113
+ return failure_block(:unreadable_graph, graph[:errors])
114
+ end
115
+
116
+ unless graph[:graph][:nodes].include?(node.to_s)
117
+ return failure_block(:unknown_node, ["node #{node} is not declared in graph.md"])
118
+ end
119
+
120
+ text = render_node_block(node: node, kind: parsed[:kind], files: parsed[:files], budget: parsed[:budget],
121
+ body: parsed[:body])
122
+ { ok: true, error_kind: nil, text: text, errors: [], kind: parsed[:kind], files: parsed[:files] || [],
123
+ budget: parsed[:budget] }
124
+ end
125
+
126
+ def failure_block(kind, errors)
127
+ { ok: false, error_kind: kind, text: nil, errors: errors, kind: nil, files: nil, budget: nil }
128
+ end
129
+ private_class_method :failure_block
130
+
131
+ def render_node_block(node:, kind:, files:, budget:, body:)
132
+ lines = []
133
+ lines << "# Node #{node}"
134
+ lines << "kind: #{kind}"
135
+ lines << "files: #{Array(files).join(', ')}"
136
+ lines << "budget: #{budget}"
137
+ lines << ""
138
+ lines << body.to_s.strip
139
+ "#{lines.join("\n")}\n"
140
+ end
141
+
142
+ # --- block 2: the ledger (retrieved data) ---------------------------------
143
+
144
+ # Every transition line for `node`, in file order, torn lines marked as
145
+ # such rather than silently dropped or read as evidence (matrix 2.4-2.6).
146
+ def ledger_lines_block(intent_dir:, node:, entries: nil)
147
+ entries ||= NodeLedger.entries(savepoint_path(intent_dir))
148
+ node_entries = entries.select { |e| e[:subject] == node.to_s }
149
+ return "(no transition lines for #{node})" if node_entries.empty?
150
+
151
+ node_entries.map { |e| e[:torn] ? "[torn] #{e[:raw]}" : e[:raw] }.join("\n")
152
+ end
153
+
154
+ # Predecessors come from graph.md's edges (327 D41 removed them from the
155
+ # node envelope, matrix 2.7); only an attributed, well-formed `done` line
156
+ # counts as evidence the predecessor actually finished (matrix 2.8).
157
+ # `entries` is an optional pre-read of the whole ledger (matrix 2.7a, B12
158
+ # of the post-execution review): `node-transition` is a concurrent
159
+ # appending writer, so re-reading `savepoint.md` once per predecessor let a
160
+ # line landing mid-build make the Transitions, Lease and attempt number of
161
+ # one packet disagree with each other. `build` reads once and threads the
162
+ # same entries through every block; a direct caller with no entries to
163
+ # share still gets a real default that reads the file itself.
164
+ def predecessor_block(intent_dir:, node:, graph_reader: GraphFile.method(:parse), entries: nil)
165
+ graph = graph_reader.call(graph_path(intent_dir))
166
+ return "(no predecessors)" unless graph[:ok]
167
+
168
+ targets = graph[:graph][:edges][node.to_s] || []
169
+ return "(no predecessors)" if targets.empty?
170
+
171
+ entries ||= NodeLedger.entries(savepoint_path(intent_dir))
172
+ targets.map do |t|
173
+ target_entries = entries.select { |e| e[:subject] == t }
174
+ evidence = target_entries.select { |e| !e[:torn] && e[:attributed] && e[:state] == "done" }.last
175
+ evidence ? "#{t}: done — #{evidence[:raw]}" : "#{t}: not yet done"
176
+ end.join("\n")
177
+ end
178
+
179
+ def lease_present?(value)
180
+ !(value.nil? || value.to_s.strip.empty?)
181
+ end
182
+ private_class_method :lease_present?
183
+
184
+ def last_running_entry(node:, entries:)
185
+ entries.select { |e| e[:subject] == node.to_s && !e[:torn] && e[:state] == "running" }.last
186
+ end
187
+ private_class_method :last_running_entry
188
+
189
+ # Whether the packet renders no lease at all (matrix 2.11a, post-execution
190
+ # review finding A1): neither a flag-supplied lease nor a recorded
191
+ # `running` line for this node. `lease_block` itself only ever renders
192
+ # `lease: none` for this case (spec D3: block 2 is retrieved data, and C7's
193
+ # stop directive is instruction, so it can never live inside that data
194
+ # block, on pain of being self-cancelling under the packet's own trust
195
+ # rule). `build` uses this to decide whether the stop directive belongs in
196
+ # block 5 instead, deduplicated against the worktree's own copy.
197
+ def lease_missing?(node:, holder:, expires:, model:, entries:)
198
+ return false if lease_present?(holder) || lease_present?(expires) || lease_present?(model)
199
+
200
+ last_running_entry(node: node, entries: entries).nil?
201
+ end
202
+
203
+ # The lease from --holder/--expires/--model when given (matrix 2.9), else
204
+ # the node's last `running` ledger line (matrix 2.10), else `lease: none`
205
+ # (matrix 2.11) with no directive of any kind: the stop directive is
206
+ # instruction (spec D3) and is rendered in block 5 by `build`, never here.
207
+ def lease_block(intent_dir:, node:, holder: nil, expires: nil, model: nil, entries: nil)
208
+ if lease_present?(holder) || lease_present?(expires) || lease_present?(model)
209
+ return "lease: holder=#{holder} expires=#{expires} model=#{model}"
210
+ end
211
+
212
+ entries ||= NodeLedger.entries(savepoint_path(intent_dir))
213
+ last = last_running_entry(node: node, entries: entries)
214
+ return "lease: none" unless last
215
+
216
+ f = last[:fields] || {}
217
+ "lease: holder=#{f['holder']} expires=#{f['expires']} model=#{f['model']}"
218
+ end
219
+
220
+ # Landed commits after a reclaim (spec D14, C11). Never shells out to git
221
+ # unless the node actually carries a `reclaimed` line (matrix 2.13); a
222
+ # failing runner degrades to a note, never an exception (matrix 2.14).
223
+ def landed_commits_block(intent_dir:, node:, files:, repo_dir:, git_runner: DEFAULT_GIT_RUNNER, entries: nil)
224
+ entries ||= NodeLedger.entries(savepoint_path(intent_dir))
225
+ node_entries = entries.select { |e| e[:subject] == node.to_s && !e[:torn] }
226
+ return nil unless node_entries.any? { |e| e[:state] == "reclaimed" }
227
+ return nil if Array(files).empty? || repo_dir.to_s.empty?
228
+
229
+ begin
230
+ out = git_runner.call(repo_dir: repo_dir, files: files)
231
+ out.to_s.strip.empty? ? "landed commits: none found for #{files.join(', ')}" : out
232
+ rescue StandardError => e
233
+ "landed commits: unavailable (#{e.class}: #{e.message})"
234
+ end
235
+ end
236
+
237
+ # --- block 3: the record (retrieved data) ---------------------------------
238
+
239
+ # {ok:, intent:, decisions: [...], insights: [...], errors:}. Only the
240
+ # three named sections are ever carried (matrix 2.15): `## Intent` in
241
+ # full (the floor, spec D8), `### Decisions` (falling back to a top-level
242
+ # `## Decisions` when the nested one is absent) split into list items, and
243
+ # the last three `## Insights` entries (matrix 2.16), with the kind-aware
244
+ # `### Findings` exclusion applied before entries are split (matrix 2.17,
245
+ # 2.18, 2.18a).
246
+ def record_block(intent_dir:, kind:)
247
+ path = Savepoint.intent_file(intent_dir)
248
+ return { ok: false, intent: nil, decisions: [], insights: [], errors: ["record file not found: #{path}"] } unless File.exist?(path)
249
+
250
+ content = File.read(path)
251
+ intent_text = section_at_level(content, 2, /\AIntent\z/i).to_s.strip
252
+ decisions_text = section_at_level(content, 3, /\ADecisions\b/i)
253
+ decisions_text = section_at_level(content, 2, /\ADecisions\b/i) if decisions_text.to_s.strip.empty?
254
+ decisions = split_list_items(decisions_text.to_s)
255
+
256
+ insights_text = section_at_level(content, 2, /\AInsights\z/i).to_s
257
+ insights_text = strip_findings(insights_text) if kind.to_s == "verify"
258
+ insights = split_insight_entries(insights_text).last(INSIGHTS_KEEP)
259
+
260
+ { ok: true, intent: intent_text, decisions: decisions, insights: insights, errors: [] }
261
+ end
262
+
263
+ def record_sources(intent_dir)
264
+ path = Savepoint.intent_file(intent_dir)
265
+ return [] unless File.exist?(path)
266
+
267
+ content = File.read(path)
268
+ return [] unless content.start_with?("---")
269
+
270
+ parts = content.split("---", 3)
271
+ return [] if parts.length < 3
272
+
273
+ fm = begin
274
+ YAML.safe_load(parts[1], permitted_classes: [Date, Time])
275
+ rescue StandardError
276
+ nil
277
+ end
278
+ return [] unless fm.is_a?(Hash)
279
+
280
+ Array(fm["sources"])
281
+ end
282
+
283
+ # --- block 4: the knowledge hop (retrieved data) --------------------------
284
+
285
+ # One level, never transitive (matrix 2.19): each source's own `## Outcome`
286
+ # and `### Decisions` (falling back to that source's spec.md `## Decisions`
287
+ # when the record carries none, matrix 2.20a, spec D13). An unresolvable
288
+ # source is noted, never raised (matrix 2.21). Capped at `hop_tokens`, with
289
+ # a truncation note when it is cut (matrix 2.22); `hop_tokens` 0 disables
290
+ # the hop entirely (matrix 2.23, spec D7/224's kill criterion).
291
+ #
292
+ # `hop=` on the running line is measured against this cap (C33, intent
293
+ # 224's kill criterion), so the reported `tokens` must never overshoot it
294
+ # by the truncation note's own cost (post-execution review finding C13):
295
+ # the note's bytes are subtracted from the byte budget before slicing, and
296
+ # the slice is trimmed to valid UTF-8 (never repaired) before it is
297
+ # counted, because a raw `byteslice` can split a multi-byte character.
298
+ # Trimming, not `String#scrub`, is what keeps the count honest: `scrub`
299
+ # repairs an invalid tail by inserting a three-byte replacement character,
300
+ # which can grow the slice back past the very budget it was cut to.
301
+ def hop_block(store_dir:, sources:, hop_tokens: DEFAULT_HOP_TOKENS)
302
+ return { text: nil, tokens: 0 } if hop_tokens.to_i <= 0 || Array(sources).empty?
303
+
304
+ chunks = Array(sources).map { |src| hop_chunk(store_dir, src) }
305
+ text = chunks.join("\n\n").scrub
306
+ cap = hop_tokens.to_i
307
+ tokens = PacketWrapper.estimate_tokens(text)
308
+ if tokens > cap
309
+ note = "\n[hop truncated at #{cap} tokens]"
310
+ max_bytes = [(cap * 4) - note.bytesize, 0].max
311
+ text = "#{safe_byteslice(text, max_bytes)}#{note}"
312
+ tokens = PacketWrapper.estimate_tokens(text)
313
+ end
314
+ { text: text, tokens: tokens }
315
+ end
316
+
317
+ # Shrinks a byte slice (never grows it) until it is valid UTF-8, so a cut
318
+ # that lands inside a multi-byte character is trimmed away rather than
319
+ # repaired with a replacement character (post-execution review finding
320
+ # C13). Bounded by `max_bytes` on every path: the result's bytesize never
321
+ # exceeds what was asked for.
322
+ def safe_byteslice(text, max_bytes)
323
+ bytes = [max_bytes.to_i, 0].max
324
+ slice = text.to_s.byteslice(0, bytes)
325
+ while slice && !slice.valid_encoding? && bytes.positive?
326
+ bytes -= 1
327
+ slice = text.to_s.byteslice(0, bytes)
328
+ end
329
+ slice && slice.valid_encoding? ? slice : ""
330
+ end
331
+ private_class_method :safe_byteslice
332
+
333
+ def hop_chunk(store_dir, source_id)
334
+ source_dir = resolve_source_dir(store_dir, source_id)
335
+ return "### #{source_id}\nunresolvable source: no directory for #{source_id.inspect}" unless source_dir
336
+
337
+ record_path = Savepoint.intent_file(source_dir)
338
+ content = File.exist?(record_path) ? File.read(record_path) : nil
339
+ outcome = content ? section_at_level(content, 2, /\AOutcome\z/i).to_s.strip : ""
340
+ decisions_text = content ? section_at_level(content, 3, /\ADecisions\b/i) : nil
341
+ if decisions_text.to_s.strip.empty?
342
+ spec_path = File.join(source_dir, "spec.md")
343
+ spec_content = File.exist?(spec_path) ? File.read(spec_path) : nil
344
+ # D13's fallback (post-execution review finding B10): `\b` here, to
345
+ # match `record_block`'s own `### Decisions` pattern, which D12's
346
+ # Findings rule already establishes tolerates a trailing qualifier on
347
+ # the heading line. `\z` required an exact "Decisions" heading, so a
348
+ # source whose spec.md carries "## Decisions (from intent Context)" or
349
+ # "## Decisions Log" (eight specs in this store do) silently rendered
350
+ # an empty hop instead of using the fallback D13 exists to provide.
351
+ decisions_text = spec_content ? section_at_level(spec_content, 2, /\ADecisions\b/i) : nil
352
+ end
353
+ "### #{source_id}\n#### Outcome\n#{outcome}\n\n#### Decisions\n#{decisions_text.to_s.strip}"
354
+ end
355
+ private_class_method :hop_chunk
356
+
357
+ # The record's and each hop source's real store-relative path (post-
358
+ # execution review finding C15, spec D5a: "source is rendered
359
+ # store-relative"): the literal constants "record" and "sources" were not
360
+ # paths at all, defeating matrix row 1.3's reason for the attribute to
361
+ # exist (telling the executor which file a paragraph came from).
362
+ def record_source_path(intent_dir)
363
+ path = Savepoint.intent_file(intent_dir)
364
+ "#{File.basename(intent_dir)}/#{File.basename(path)}"
365
+ end
366
+
367
+ def hop_source_paths(store_dir, sources)
368
+ Array(sources).map do |src|
369
+ dir = resolve_source_dir(store_dir, src)
370
+ dir ? "#{File.basename(dir)}/#{File.basename(Savepoint.intent_file(dir))}" : "#{src} (unresolved)"
371
+ end.join(", ")
372
+ end
373
+
374
+ def resolve_source_dir(store_dir, source_id)
375
+ exact = File.join(store_dir, source_id.to_s)
376
+ return exact if File.directory?(exact)
377
+
378
+ Dir.glob(File.join(store_dir, "#{source_id}--*")).select { |p| File.directory?(p) }.sort.first
379
+ end
380
+ private_class_method :resolve_source_dir
381
+
382
+ # --- block 5: where to work (instruction) ---------------------------------
383
+
384
+ def worktree_block(intent_dir:, worktree_reader: Arm.method(:worktree_block))
385
+ info = worktree_reader.call(intent_dir: intent_dir)
386
+ if info && info["provisioned"]
387
+ "worktree: #{info['code']} (branch #{info['code_branch']})"
388
+ else
389
+ "worktree: none provisioned\n#{STOP_DIRECTIVE}"
390
+ end
391
+ end
392
+
393
+ def default_project_reader(intent_dir)
394
+ m = intent_dir.to_s.match(PROJECT_LAYOUT_RE)
395
+ return nil unless m
396
+
397
+ home, slug = m[1], m[2]
398
+ path = File.join(home, "projects", slug, "project.yml")
399
+ return nil unless File.exist?(path)
400
+
401
+ # `permitted_classes` (post-execution review finding B3): the same bug
402
+ # class `record_sources` already fixed once in 607e31e. Any project.yml
403
+ # that gains a date-typed value (a `created:` field, say) silently
404
+ # stripped the test command from every packet for that project, since
405
+ # the rescue swallowed `Psych::DisallowedClass` and returned nil.
406
+ data = begin
407
+ YAML.safe_load(File.read(path), permitted_classes: [Date, Time])
408
+ rescue StandardError
409
+ nil
410
+ end
411
+ return nil unless data.is_a?(Hash)
412
+
413
+ release = data["release"]
414
+ return nil unless release.is_a?(Hash)
415
+
416
+ verify = release["verify"]
417
+ return nil unless verify.is_a?(String) && !verify.strip.empty?
418
+
419
+ # A multi-line `verify` (post-execution review finding A2) is collapsed
420
+ # to one line rather than refused: each of its own lines is joined with
421
+ # "; ", the shell-sequencing separator, so "ruby bin/test\necho done"
422
+ # reads as "ruby bin/test; echo done" instead of landing as extra raw
423
+ # lines in block 5 (instruction, un-wrapped) where one of those lines
424
+ # could happen to be a complete data marker.
425
+ verify.split("\n").map(&:strip).reject(&:empty?).join("; ")
426
+ end
427
+
428
+ def test_command_block(intent_dir:, project_reader: method(:default_project_reader))
429
+ cmd = project_reader.call(intent_dir)
430
+ cmd ? "test command: #{cmd}" : "test command: none recorded in the project record"
431
+ end
432
+
433
+ # `lease_missing` (post-execution review finding A1) hoists C7's stop
434
+ # directive here, block 5 (instruction, spec D3), whenever the packet
435
+ # carries no lease. `worktree_block` already renders its own copy when the
436
+ # worktree is unprovisioned; the two conditions often fire together, so a
437
+ # directive already present is never repeated.
438
+ def where_to_work_block(intent_dir:, worktree_reader: Arm.method(:worktree_block),
439
+ project_reader: method(:default_project_reader), lease_missing: false)
440
+ wt = worktree_block(intent_dir: intent_dir, worktree_reader: worktree_reader)
441
+ parts = [wt, test_command_block(intent_dir: intent_dir, project_reader: project_reader)]
442
+ parts << STOP_DIRECTIVE if lease_missing && !wt.include?(STOP_DIRECTIVE)
443
+ parts.join("\n")
444
+ end
445
+
446
+ # --- section and list parsing (shared) -------------------------------------
447
+
448
+ # The body of the FIRST heading, at exactly `level` `#` characters, whose
449
+ # text (after the marks) matches `title_re`, up to (excluding) the next
450
+ # heading at `level` or shallower, or EOF. Fence-aware via
451
+ # NodeFile.each_fence_line, so a fenced example line starting with `#`
452
+ # never ends a section early. nil when no such heading exists.
453
+ def section_at_level(content, level, title_re)
454
+ in_section = false
455
+ found = false
456
+ body_lines = []
457
+ NodeFile.each_fence_line(content.to_s) do |line, fenced|
458
+ if fenced
459
+ body_lines << line if in_section
460
+ next
461
+ end
462
+
463
+ m = line.match(/\A(#+)[ \t]+(.*?)\s*\z/)
464
+ if in_section
465
+ if m && m[1].length <= level
466
+ in_section = false
467
+ else
468
+ body_lines << line
469
+ end
470
+ elsif m && m[1].length == level && m[2] =~ title_re
471
+ in_section = true
472
+ found = true
473
+ end
474
+ end
475
+ found ? body_lines.join : nil
476
+ end
477
+
478
+ # A Markdown bullet list split into items: a line starting with `- ` opens
479
+ # a new item, and every following line up to the next such line is that
480
+ # item's continuation (spec: Decisions cut to "the last five", matrix
481
+ # 3.8, which only makes sense over list items, not raw lines).
482
+ def split_list_items(text)
483
+ items = []
484
+ current = nil
485
+ text.to_s.each_line do |line|
486
+ if line.match?(/\A-\s/)
487
+ items << current if current
488
+ current = +line
489
+ elsif current
490
+ current << line
491
+ end
492
+ end
493
+ items << current if current
494
+ items
495
+ end
496
+
497
+ # `## Insights` entries split by `Insights::PREFIX_RE` (matrix 2.16a): a
498
+ # continuation line (one that does not itself open a new prefixed entry)
499
+ # is appended to the entry it follows, never counted as its own entry.
500
+ # Text before the first prefixed line (the scaffold placeholder, matrix
501
+ # 2.16b) is dropped rather than treated as an entry.
502
+ def split_insight_entries(text)
503
+ entries = []
504
+ current = nil
505
+ text.to_s.each_line do |line|
506
+ if line.match?(Insights::PREFIX_RE)
507
+ entries << current if current
508
+ current = +line
509
+ elsif current
510
+ current << line
511
+ end
512
+ end
513
+ entries << current if current
514
+ entries
515
+ end
516
+
517
+ # Remove any `### Findings` subsection (tolerating a trailing qualifier on
518
+ # the heading line, matrix 2.18a) from `text`, up to the next heading at
519
+ # level 3 or shallower, or EOF. Anchored to whatever section `text` already
520
+ # is (the caller passes only the `## Insights` body), so a `### Findings`
521
+ # living under a DIFFERENT section (matrix 2.18a's intent-109 case, `##
522
+ # Context`) is never reached because it is never part of `text`.
523
+ def strip_findings(text)
524
+ lines = text.to_s.each_line.to_a
525
+ out = []
526
+ i = 0
527
+ while i < lines.length
528
+ m = lines[i].match(/\A(#+)[ \t]+(.*?)\s*\z/)
529
+ if m && m[1].length == 3 && m[2] =~ /\AFindings\b/i
530
+ i += 1
531
+ while i < lines.length
532
+ m2 = lines[i].match(/\A(#+)[ \t]+/)
533
+ break if m2 && m2[1].length <= 3
534
+
535
+ i += 1
536
+ end
537
+ next
538
+ end
539
+ out << lines[i]
540
+ i += 1
541
+ end
542
+ out.join
543
+ end
544
+
545
+ # ===========================================================================
546
+ # n3: assembly, the budget, and the packet's identity
547
+ # ===========================================================================
548
+
549
+ # Block labels/sources for the wrapped (data) blocks, spec D3.
550
+ LEDGER_LABEL = "ledger"
551
+ RECORD_LABEL = "record"
552
+ HOP_LABEL = "knowledge hop"
553
+
554
+ # The whole "ledger" data block (spec block 2): the node's own transition
555
+ # lines, its predecessors' evidence, its lease, and any landed commits
556
+ # after a reclaim.
557
+ def full_ledger_text(intent_dir:, node:, files:, holder:, expires:, model:, repo_dir:, git_runner:, entries: nil)
558
+ entries ||= NodeLedger.entries(savepoint_path(intent_dir))
559
+ landed = landed_commits_block(intent_dir: intent_dir, node: node, files: files, repo_dir: repo_dir,
560
+ git_runner: git_runner, entries: entries)
561
+ parts = [
562
+ "### Transitions",
563
+ ledger_lines_block(intent_dir: intent_dir, node: node, entries: entries),
564
+ "",
565
+ "### Predecessors",
566
+ predecessor_block(intent_dir: intent_dir, node: node, entries: entries),
567
+ "",
568
+ "### Lease",
569
+ lease_block(intent_dir: intent_dir, node: node, holder: holder, expires: expires, model: model,
570
+ entries: entries),
571
+ ]
572
+ if landed
573
+ parts << ""
574
+ parts << "### Landed commits"
575
+ parts << landed
576
+ end
577
+ parts.join("\n")
578
+ end
579
+
580
+ # The record's Intent/Decisions/Insights rendered as one payload, over
581
+ # whatever (possibly already-cut) decisions/insights arrays the cut ladder
582
+ # is currently holding.
583
+ def render_record_text(intent:, decisions:, insights:)
584
+ parts = ["## Intent", intent.to_s.strip, ""]
585
+ parts << "## Decisions"
586
+ parts << (decisions.empty? ? "(none)" : decisions.join.rstrip)
587
+ parts << ""
588
+ parts << "## Insights"
589
+ parts << (insights.empty? ? "(none)" : insights.join.rstrip)
590
+ "#{parts.join("\n")}\n"
591
+ end
592
+
593
+ # One packet, node and where-to-work as plain instruction text, ledger,
594
+ # record and (when present) hop wrapped as labeled data sharing ONE
595
+ # boundary token computed over their raw payloads (spec D2-D4, matrix
596
+ # 3.1-3.3).
597
+ #
598
+ # Blocks 1 and 5 are raw-interpolated (spec D3: instruction, not data), but
599
+ # that trust does not reach a `release.verify` a project.yml can carry
600
+ # (post-execution review finding A2): a line that is, on its own, a
601
+ # complete data marker is disarmed by `PacketWrapper.neutralize_marker_lines`
602
+ # before it ever reaches the packet, so a forged marker cannot open a
603
+ # block outside the wrapper's own boundary. `record_source`/`hop_source`
604
+ # (finding C15) are the record's and the hop sources' real store-relative
605
+ # paths, never the placeholder label "record"/"sources".
606
+ def render_packet(node_text:, ledger_text:, intent_text:, decisions:, insights:, hop:, where_text:,
607
+ record_source: "record", hop_source: "sources")
608
+ record_text = render_record_text(intent: intent_text, decisions: decisions, insights: insights)
609
+ hop_text = hop && hop[:text]
610
+
611
+ payloads = [ledger_text, record_text]
612
+ payloads << hop_text if hop_text
613
+ token = PacketWrapper.boundary_token(payloads)
614
+
615
+ node_text_safe = PacketWrapper.neutralize_marker_lines(node_text.to_s)
616
+ where_text_safe = PacketWrapper.neutralize_marker_lines(where_text.to_s)
617
+
618
+ # Normalized through `attr_safe` exactly as `wrap` normalizes the marker
619
+ # attributes it writes (post-execution review finding A2's integrity
620
+ # check): comparing the raw `record_source`/`hop_source` against what
621
+ # `unwrap` reads back off the rendered marker line raised on every nil
622
+ # source, because `attr_safe(nil)` renders as `""`, not `"nil".to_s`.
623
+ wrapped_specs = [
624
+ { label: PacketWrapper.attr_safe(LEDGER_LABEL), source: PacketWrapper.attr_safe("savepoint.md") },
625
+ { label: PacketWrapper.attr_safe(RECORD_LABEL), source: PacketWrapper.attr_safe(record_source) },
626
+ ]
627
+ wrapped_specs << { label: PacketWrapper.attr_safe(HOP_LABEL), source: PacketWrapper.attr_safe(hop_source) } if hop_text
628
+
629
+ parts = [node_text_safe.rstrip, ""]
630
+ parts << PacketWrapper.wrap(ledger_text, label: LEDGER_LABEL, source: "savepoint.md", token: token).rstrip
631
+ parts << ""
632
+ parts << PacketWrapper.wrap(record_text, label: RECORD_LABEL, source: record_source, token: token).rstrip
633
+ parts << ""
634
+ if hop_text
635
+ parts << PacketWrapper.wrap(hop_text, label: HOP_LABEL, source: hop_source, token: token).rstrip
636
+ parts << ""
637
+ end
638
+ parts << where_text_safe.rstrip
639
+ rendered = "#{parts.join("\n")}\n"
640
+
641
+ assert_packet_integrity!(rendered, wrapped_specs)
642
+ rendered
643
+ end
644
+
645
+ # The trust boundary's own invariant (post-execution review finding A2):
646
+ # the finished packet must unwrap to exactly the data blocks that were
647
+ # wrapped, same count, same labels, same sources, in order. This is what
648
+ # the escaping rule and the marker-line neutralization pass are FOR, so the
649
+ # check belongs here, not only in a test that could rot independently of
650
+ # the code it is meant to guard.
651
+ def assert_packet_integrity!(rendered, wrapped_specs)
652
+ actual = PacketWrapper.unwrap(rendered).map { |b| { label: b[:label], source: b[:source] } }
653
+ return if actual == wrapped_specs
654
+
655
+ raise "node packet integrity check failed: expected #{wrapped_specs.inspect}, got #{actual.inspect}"
656
+ end
657
+ private_class_method :assert_packet_integrity!
658
+
659
+ def render_from_state(state)
660
+ render_packet(node_text: state[:node_text], ledger_text: state[:ledger_text], intent_text: state[:intent_text],
661
+ decisions: state[:decisions], insights: state[:insights], hop: state[:hop],
662
+ where_text: state[:where_text], record_source: state[:record_source] || "record",
663
+ hop_source: state[:hop_source] || "sources")
664
+ end
665
+ private_class_method :render_from_state
666
+
667
+ def estimate_rendered_tokens(rendered)
668
+ PacketWrapper.estimate_tokens(rendered)
669
+ end
670
+
671
+ # The C28 cut ladder: drop the hop whole, then cut Insights to the last
672
+ # one, then cut Decisions to the last five - applied only as far as
673
+ # needed, and only when a step actually shrinks the rendered bytes (matrix
674
+ # 3.23: a cut that would not reduce the render is skipped rather than
675
+ # counted as applied). The node, ledger and where-to-work blocks are never
676
+ # touched (matrix 3.9) because nothing here ever rewrites those keys.
677
+ def apply_cut_ladder(state, budget_tokens)
678
+ cuts = []
679
+ current = state
680
+ rendered = render_from_state(current)
681
+ tokens = estimate_rendered_tokens(rendered)
682
+ return [rendered, tokens, cuts] if tokens <= budget_tokens
683
+
684
+ if current[:hop] && current[:hop][:text]
685
+ candidate_state = current.merge(hop: { text: nil, tokens: 0 })
686
+ candidate = render_from_state(candidate_state)
687
+ if candidate.bytesize < rendered.bytesize
688
+ current, rendered = candidate_state, candidate
689
+ tokens = estimate_rendered_tokens(rendered)
690
+ cuts << :hop
691
+ end
692
+ end
693
+ return [rendered, tokens, cuts] if tokens <= budget_tokens
694
+
695
+ if current[:insights].length > 1
696
+ candidate_state = current.merge(insights: current[:insights].last(1))
697
+ candidate = render_from_state(candidate_state)
698
+ if candidate.bytesize < rendered.bytesize
699
+ current, rendered = candidate_state, candidate
700
+ tokens = estimate_rendered_tokens(rendered)
701
+ cuts << :insights
702
+ end
703
+ end
704
+ return [rendered, tokens, cuts] if tokens <= budget_tokens
705
+
706
+ if current[:decisions].length > DECISIONS_KEEP
707
+ candidate_state = current.merge(decisions: current[:decisions].last(DECISIONS_KEEP))
708
+ candidate = render_from_state(candidate_state)
709
+ if candidate.bytesize < rendered.bytesize
710
+ current, rendered = candidate_state, candidate
711
+ tokens = estimate_rendered_tokens(rendered)
712
+ cuts << :decisions
713
+ end
714
+ end
715
+
716
+ [rendered, tokens, cuts]
717
+ end
718
+ private_class_method :apply_cut_ladder
719
+
720
+ # Which never-cut-or-already-at-floor block is largest, so a refusal names
721
+ # what to shorten (matrix 3.24) rather than just saying "too big".
722
+ def name_oversized_block(state)
723
+ record_text = render_record_text(intent: state[:intent_text], decisions: state[:decisions],
724
+ insights: state[:insights])
725
+ candidates = {
726
+ "node" => state[:node_text],
727
+ "ledger" => state[:ledger_text],
728
+ "record" => record_text,
729
+ "where to work" => state[:where_text],
730
+ }
731
+ name, text = candidates.max_by { |_, t| PacketWrapper.estimate_tokens(t.to_s) }
732
+ [name, PacketWrapper.estimate_tokens(text.to_s)]
733
+ end
734
+ private_class_method :name_oversized_block
735
+
736
+ def lease_flag_given?(holder)
737
+ lease_present?(holder)
738
+ end
739
+ private_class_method :lease_flag_given?
740
+
741
+ # C21: the number of `running` lines already recorded for `node`, plus one
742
+ # when a lease is being supplied by flag (a NEW dispatch), floored at 1.
743
+ def compute_attempt_number(intent_dir:, node:, lease_flag_given:, entries: nil)
744
+ entries ||= NodeLedger.entries(savepoint_path(intent_dir))
745
+ count = entries.count { |e| e[:subject] == node.to_s && e[:state] == "running" }
746
+ [count + (lease_flag_given ? 1 : 0), 1].max
747
+ end
748
+
749
+ def packet_path(intent_dir:, node:, attempt:)
750
+ File.join(intent_dir, "packets", "#{node}--a#{attempt}.packet")
751
+ end
752
+
753
+ def summary_line(result)
754
+ "path=#{result[:path]} sha=#{result[:sha]} tokens=#{result[:tokens]} hop_tokens=#{result[:hop_tokens]} " \
755
+ "attempt=#{result[:attempt]}"
756
+ end
757
+
758
+ def running_command(intent_dir:, node:, sha:, hop_tokens:)
759
+ "node-transition #{intent_dir} --node #{node} --state running --field packet=#{sha} --field hop=#{hop_tokens}"
760
+ end
761
+
762
+ def needs_decision_command(intent_dir:, node:, question:)
763
+ escaped = question.to_s.gsub("\\", "\\\\\\\\").gsub('"', "\\\"")
764
+ "node-transition #{intent_dir} --node #{node} --state needs_decision --field question=\"#{escaped}\""
765
+ end
766
+
767
+ # Build one node's whole packet from disk (spec D1). Returns
768
+ # {ok:, exit_code:, path:, sha:, tokens:, hop_tokens:, attempt:,
769
+ # cuts_applied:, running_command:, errors:} on success, or
770
+ # {ok: false, exit_code:, errors:, needs_decision_command: (on overflow)}
771
+ # on refusal. Exit codes follow the node-transition family (spec D17): 2
772
+ # usage (unknown node), 3 unreadable/unparsable graph, node file or
773
+ # record, 4 overflow past the third cut, 5 an existing attempt whose bytes
774
+ # differ.
775
+ def build(intent_dir:, node:, budget_tokens: DEFAULT_BUDGET_TOKENS, hop_tokens: DEFAULT_HOP_TOKENS,
776
+ holder: nil, expires: nil, model: nil, attempt: nil, out: nil, force: false,
777
+ renamer: File.method(:rename), git_runner: DEFAULT_GIT_RUNNER,
778
+ worktree_reader: Arm.method(:worktree_block), project_reader: method(:default_project_reader))
779
+ intent_dir = File.expand_path(intent_dir)
780
+
781
+ nb = node_block(intent_dir: intent_dir, node: node)
782
+ unless nb[:ok]
783
+ return { ok: false, exit_code: nb[:error_kind] == :unknown_node ? 2 : 3, errors: nb[:errors] }
784
+ end
785
+
786
+ record = record_block(intent_dir: intent_dir, kind: nb[:kind])
787
+ return { ok: false, exit_code: 3, errors: record[:errors] } unless record[:ok]
788
+
789
+ repo_dir = begin
790
+ info = worktree_reader.call(intent_dir: intent_dir)
791
+ info && info["code"]
792
+ rescue StandardError
793
+ nil
794
+ end
795
+
796
+ # Read the ledger once and thread it through every block that consults
797
+ # it (post-execution review finding B12): `node-transition` is a
798
+ # concurrent appending writer, so re-reading `savepoint.md` once per
799
+ # block risked a line landing mid-build and making the Transitions,
800
+ # Lease and attempt number of one packet disagree with each other.
801
+ entries = NodeLedger.entries(savepoint_path(intent_dir))
802
+
803
+ ledger_text = full_ledger_text(intent_dir: intent_dir, node: node, files: nb[:files], holder: holder,
804
+ expires: expires, model: model, repo_dir: repo_dir, git_runner: git_runner,
805
+ entries: entries)
806
+
807
+ store_dir = File.dirname(intent_dir)
808
+ sources = record_sources(intent_dir)
809
+ hop_full = hop_block(store_dir: store_dir, sources: sources, hop_tokens: hop_tokens)
810
+
811
+ # Finding A1: the stop directive belongs in block 5 (instruction)
812
+ # whenever the packet carries no lease at all, never inside block 2's
813
+ # ledger data (spec D3's self-cancellation risk).
814
+ missing_lease = lease_missing?(node: node, holder: holder, expires: expires, model: model, entries: entries)
815
+ where_text = where_to_work_block(intent_dir: intent_dir, worktree_reader: worktree_reader,
816
+ project_reader: project_reader, lease_missing: missing_lease)
817
+
818
+ state = {
819
+ node_text: nb[:text], ledger_text: ledger_text, intent_text: record[:intent],
820
+ decisions: record[:decisions], insights: record[:insights], hop: hop_full, where_text: where_text,
821
+ # Finding C15: the record's and the hop sources' real store-relative
822
+ # paths, never the placeholder labels "record"/"sources".
823
+ record_source: record_source_path(intent_dir), hop_source: hop_source_paths(store_dir, sources),
824
+ }
825
+
826
+ rendered, tokens, cuts_applied = apply_cut_ladder(state, budget_tokens)
827
+
828
+ if tokens > budget_tokens
829
+ final_state = state.merge(
830
+ hop: cuts_applied.include?(:hop) ? { text: nil, tokens: 0 } : state[:hop],
831
+ insights: cuts_applied.include?(:insights) ? state[:insights].last(1) : state[:insights],
832
+ decisions: cuts_applied.include?(:decisions) ? state[:decisions].last(DECISIONS_KEEP) : state[:decisions],
833
+ )
834
+ oversized_name, oversized_tokens = name_oversized_block(final_state)
835
+ question = "packet for #{node} is #{tokens} tokens after every cut, over the #{budget_tokens}-token " \
836
+ "budget; #{oversized_name} alone is #{oversized_tokens} tokens, shorten it"
837
+ return {
838
+ ok: false, exit_code: 4, tokens: tokens,
839
+ needs_decision_command: needs_decision_command(intent_dir: intent_dir, node: node, question: question),
840
+ errors: ["overflow: #{oversized_name} is #{oversized_tokens} tokens over the #{budget_tokens}-token budget"],
841
+ }
842
+ end
843
+
844
+ attempt_n = attempt || compute_attempt_number(intent_dir: intent_dir, node: node,
845
+ lease_flag_given: lease_flag_given?(holder), entries: entries)
846
+ path = out ? File.expand_path(out) : packet_path(intent_dir: intent_dir, node: node, attempt: attempt_n)
847
+ FileUtils.mkdir_p(File.dirname(path))
848
+
849
+ if File.exist?(path)
850
+ existing = File.binread(path)
851
+ if existing == rendered
852
+ # Rebuilding an unchanged attempt is a no-op (spec D11): the file on
853
+ # disk already IS these exact bytes.
854
+ elsif force
855
+ AtomicWrite.write(path, rendered, renamer: renamer)
856
+ else
857
+ return { ok: false, exit_code: 5, errors: ["attempt file exists with different bytes: #{path}"] }
858
+ end
859
+ else
860
+ AtomicWrite.write(path, rendered, renamer: renamer)
861
+ end
862
+
863
+ sha = Digest::SHA256.hexdigest(File.binread(path))[0, 12]
864
+ hop_tokens_measured = cuts_applied.include?(:hop) ? 0 : hop_full[:tokens].to_i
865
+
866
+ {
867
+ ok: true, exit_code: 0, path: path, sha: sha, tokens: tokens, hop_tokens: hop_tokens_measured,
868
+ attempt: attempt_n, cuts_applied: cuts_applied,
869
+ running_command: running_command(intent_dir: intent_dir, node: node, sha: sha, hop_tokens: hop_tokens_measured),
870
+ errors: [],
871
+ }
872
+ end
873
+ end