@zalom/plastic 2.0.0-alpha.2 → 2.0.0-alpha.21

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 (127) hide show
  1. package/agents/plastic-enforcer.md +3 -1
  2. package/agents/plastic-executor.md +9 -3
  3. package/bin/test +24 -4
  4. package/hooks/call-budget +4 -0
  5. package/hooks/hooks.json +24 -0
  6. package/hooks/message-display +134 -0
  7. package/hooks/statusline +4 -27
  8. package/package.json +2 -2
  9. package/scripts/agent-report +8 -2
  10. package/scripts/dashboard.rb +297 -18
  11. package/scripts/doctor.rb +652 -5
  12. package/scripts/end-intent +219 -0
  13. package/scripts/hook-call-budget +222 -0
  14. package/scripts/hook-capture +25 -113
  15. package/scripts/hook-message-display +81 -0
  16. package/scripts/hook-record +12 -4
  17. package/scripts/hook-session-start +45 -7
  18. package/scripts/index-projection +74 -0
  19. package/scripts/intent-screen +77 -0
  20. package/scripts/lib/action_graph_shim.rb +277 -0
  21. package/scripts/lib/arm.rb +26 -1
  22. package/scripts/lib/atomic_write.rb +31 -0
  23. package/scripts/lib/compact_instructions.rb +5 -5
  24. package/scripts/lib/core_integrity.rb +71 -0
  25. package/scripts/lib/dashboard_screen.rb +40 -0
  26. package/scripts/lib/day_summary.rb +7 -2
  27. package/scripts/lib/doctor_core.rb +102 -5
  28. package/scripts/lib/doctor_session_ledger.rb +52 -0
  29. package/scripts/lib/graph_edges.rb +137 -0
  30. package/scripts/lib/graph_file.rb +246 -0
  31. package/scripts/lib/graph_tree.rb +98 -0
  32. package/scripts/lib/guarded_append.rb +155 -0
  33. package/scripts/lib/hook_registry.rb +28 -2
  34. package/scripts/lib/hook_replay.rb +211 -0
  35. package/scripts/lib/index_projection.rb +201 -0
  36. package/scripts/lib/installer_core.rb +141 -6
  37. package/scripts/lib/intent_screen.rb +309 -0
  38. package/scripts/lib/intent_screen_ansi.rb +262 -0
  39. package/scripts/lib/message_display.rb +586 -0
  40. package/scripts/lib/meter_watch.rb +179 -0
  41. package/scripts/lib/node_file.rb +214 -0
  42. package/scripts/lib/node_ids.rb +99 -0
  43. package/scripts/lib/node_ledger.rb +377 -0
  44. package/scripts/lib/node_packet.rb +908 -0
  45. package/scripts/lib/node_return.rb +199 -0
  46. package/scripts/lib/node_worktree.rb +337 -0
  47. package/scripts/lib/outcome_report.rb +440 -0
  48. package/scripts/lib/packet_wrapper.rb +132 -0
  49. package/scripts/lib/ready_set.rb +462 -0
  50. package/scripts/lib/release_guard.rb +16 -0
  51. package/scripts/lib/report_screen.rb +1967 -0
  52. package/scripts/lib/roadmap_graph.rb +210 -0
  53. package/scripts/lib/roadmap_migration.rb +95 -0
  54. package/scripts/lib/roadmap_queue.rb +155 -5
  55. package/scripts/lib/roadmap_render.rb +150 -0
  56. package/scripts/lib/roadmap_savepoint.rb +62 -12
  57. package/scripts/lib/runner_absorb.rb +620 -0
  58. package/scripts/lib/runner_answer.rb +206 -0
  59. package/scripts/lib/runner_core.rb +194 -0
  60. package/scripts/lib/runner_dispatch.rb +506 -0
  61. package/scripts/lib/runner_policy.rb +173 -0
  62. package/scripts/lib/runner_proposals.rb +275 -0
  63. package/scripts/lib/runner_rewind.rb +201 -0
  64. package/scripts/lib/runner_sweep.rb +231 -0
  65. package/scripts/lib/savepoint.rb +149 -12
  66. package/scripts/lib/screen_paint.rb +555 -0
  67. package/scripts/lib/screens/dashboard.rb +20 -0
  68. package/scripts/lib/screens/plan.rb +18 -0
  69. package/scripts/lib/screens/roadmap.rb +15 -0
  70. package/scripts/lib/session_git.rb +49 -18
  71. package/scripts/lib/session_ledger.rb +128 -0
  72. package/scripts/lib/session_usage.rb +190 -0
  73. package/scripts/lib/verify_intent.rb +33 -0
  74. package/scripts/lib/work_graph_validator.rb +201 -0
  75. package/scripts/meter-watch +57 -0
  76. package/scripts/node-packet +92 -0
  77. package/scripts/node-transition +291 -0
  78. package/scripts/outcome-report +74 -0
  79. package/scripts/plastic-lock +8 -1
  80. package/scripts/read-config +3 -3
  81. package/scripts/ready-set +126 -0
  82. package/scripts/release-check +118 -0
  83. package/scripts/report-screen +281 -0
  84. package/scripts/roadmap-graph +119 -0
  85. package/scripts/roadmap-savepoint +7 -0
  86. package/scripts/runner +397 -0
  87. package/scripts/savepoint-note +69 -0
  88. package/scripts/session-usage +56 -0
  89. package/scripts/spawn-preamble +9 -2
  90. package/scripts/validate-work-graph +39 -0
  91. package/scripts/verify-intent +2 -1
  92. package/skills/auto/SKILL.md +41 -34
  93. package/skills/auto/references/human-report-contract.md +136 -54
  94. package/skills/conventions/references/locks-and-worktrees.md +12 -0
  95. package/skills/dashboard/SKILL.md +13 -2
  96. package/skills/dashboard/templates/dashboard-global.md +1 -1
  97. package/skills/dashboard/templates/dashboard-project.md +2 -2
  98. package/skills/doctor/SKILL.md +10 -4
  99. package/skills/intent-continuing/SKILL.md +51 -41
  100. package/skills/intent-continuing/references/board-fill.md +9 -0
  101. package/skills/intent-continuing/references/boarding-matrix.md +6 -5
  102. package/skills/intent-continuing/references/context-management.md +1 -1
  103. package/skills/intent-ending/SKILL.md +36 -16
  104. package/skills/intent-executing/SKILL.md +21 -5
  105. package/skills/intent-executing/implementer-prompt.md +6 -1
  106. package/skills/intent-speccing/SKILL.md +7 -4
  107. package/skills/releasing/SKILL.md +39 -0
  108. package/skills/releasing/references/promotion-and-tagging.md +10 -6
  109. package/skills/releasing/references/release-lines.md +1 -1
  110. package/skills/roadmap/SKILL.md +26 -0
  111. package/skills/roadmap/references/file-format.md +10 -0
  112. package/templates/config.yml +3 -3
  113. package/templates/dashboard-screen.md +22 -0
  114. package/templates/display-fixture.md +21 -0
  115. package/templates/graph.md +16 -0
  116. package/templates/intent-screen.md +17 -0
  117. package/templates/node-decision.md +11 -0
  118. package/templates/node-research.md +11 -0
  119. package/templates/node-verify.md +13 -0
  120. package/templates/node-work.md +22 -0
  121. package/templates/outcome.md +19 -1
  122. package/templates/report-plan.md +15 -0
  123. package/templates/report-roadmap-delivered.md +10 -0
  124. package/templates/report-roadmap-plan.md +9 -0
  125. package/templates/report-roadmap-state.md +9 -0
  126. package/templates/report-state.md +11 -0
  127. package/templates/roadmap.md +13 -0
@@ -0,0 +1,908 @@
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
+ # `files` (intent 355, n4, D5): a node's own declared `*_test.rb` files
429
+ # name the only test command the executor needs - `bin/test --only <those
430
+ # files>` - so it never has to invent one or fall back to the project's
431
+ # generic `release.verify`. A node that declares no test files (a docs-only
432
+ # node, say) still falls back to `project_reader` exactly as before.
433
+ def test_command_block(intent_dir:, files: [], project_reader: method(:default_project_reader))
434
+ named = Array(files).select { |f| f.to_s.end_with?("_test.rb") }
435
+ return "test command: ruby bin/test --only #{named.join(' ')}" if named.any?
436
+
437
+ cmd = project_reader.call(intent_dir)
438
+ cmd ? "test command: #{cmd}" : "test command: none recorded in the project record"
439
+ end
440
+
441
+ # `lease_missing` (post-execution review finding A1) hoists C7's stop
442
+ # directive here, block 5 (instruction, spec D3), whenever the packet
443
+ # carries no lease. `worktree_block` already renders its own copy when the
444
+ # worktree is unprovisioned; the two conditions often fire together, so a
445
+ # directive already present is never repeated.
446
+ #
447
+ # `call_cap` (intent 355, n2, D2): one sentence naming this attempt's tool
448
+ # call cap and the return it hits at, so the executor learns the number
449
+ # from the packet it starts with, never from a denied call mid-edit
450
+ # (matrix 2.4). nil (a caller that names no cap) renders nothing here.
451
+ def where_to_work_block(intent_dir:, worktree_reader: Arm.method(:worktree_block),
452
+ project_reader: method(:default_project_reader), lease_missing: false, call_cap: nil,
453
+ files: [])
454
+ wt = worktree_block(intent_dir: intent_dir, worktree_reader: worktree_reader)
455
+ parts = [wt, test_command_block(intent_dir: intent_dir, files: files, project_reader: project_reader)]
456
+ parts << STOP_DIRECTIVE if lease_missing && !wt.include?(STOP_DIRECTIVE)
457
+ parts << call_cap_sentence(call_cap) if call_cap
458
+ parts.join("\n")
459
+ end
460
+
461
+ def call_cap_sentence(call_cap)
462
+ "call budget: this attempt may make at most #{call_cap} tool calls; past that a hook denies the " \
463
+ "next one, so commit what is green and return failed_verification reason=call_budget."
464
+ end
465
+
466
+ # --- section and list parsing (shared) -------------------------------------
467
+
468
+ # The body of the FIRST heading, at exactly `level` `#` characters, whose
469
+ # text (after the marks) matches `title_re`, up to (excluding) the next
470
+ # heading at `level` or shallower, or EOF. Fence-aware via
471
+ # NodeFile.each_fence_line, so a fenced example line starting with `#`
472
+ # never ends a section early. nil when no such heading exists.
473
+ def section_at_level(content, level, title_re)
474
+ in_section = false
475
+ found = false
476
+ body_lines = []
477
+ NodeFile.each_fence_line(content.to_s) do |line, fenced|
478
+ if fenced
479
+ body_lines << line if in_section
480
+ next
481
+ end
482
+
483
+ m = line.match(/\A(#+)[ \t]+(.*?)\s*\z/)
484
+ if in_section
485
+ if m && m[1].length <= level
486
+ in_section = false
487
+ else
488
+ body_lines << line
489
+ end
490
+ elsif m && m[1].length == level && m[2] =~ title_re
491
+ in_section = true
492
+ found = true
493
+ end
494
+ end
495
+ found ? body_lines.join : nil
496
+ end
497
+
498
+ # A Markdown bullet list split into items: a line starting with `- ` opens
499
+ # a new item, and every following line up to the next such line is that
500
+ # item's continuation (spec: Decisions cut to "the last five", matrix
501
+ # 3.8, which only makes sense over list items, not raw lines).
502
+ def split_list_items(text)
503
+ items = []
504
+ current = nil
505
+ text.to_s.each_line do |line|
506
+ if line.match?(/\A-\s/)
507
+ items << current if current
508
+ current = +line
509
+ elsif current
510
+ current << line
511
+ end
512
+ end
513
+ items << current if current
514
+ items
515
+ end
516
+
517
+ # `## Insights` entries split by `Insights::PREFIX_RE` (matrix 2.16a): a
518
+ # continuation line (one that does not itself open a new prefixed entry)
519
+ # is appended to the entry it follows, never counted as its own entry.
520
+ # Text before the first prefixed line (the scaffold placeholder, matrix
521
+ # 2.16b) is dropped rather than treated as an entry.
522
+ def split_insight_entries(text)
523
+ entries = []
524
+ current = nil
525
+ text.to_s.each_line do |line|
526
+ if line.match?(Insights::PREFIX_RE)
527
+ entries << current if current
528
+ current = +line
529
+ elsif current
530
+ current << line
531
+ end
532
+ end
533
+ entries << current if current
534
+ entries
535
+ end
536
+
537
+ # Remove any `### Findings` subsection (tolerating a trailing qualifier on
538
+ # the heading line, matrix 2.18a) from `text`, up to the next heading at
539
+ # level 3 or shallower, or EOF. Anchored to whatever section `text` already
540
+ # is (the caller passes only the `## Insights` body), so a `### Findings`
541
+ # living under a DIFFERENT section (matrix 2.18a's intent-109 case, `##
542
+ # Context`) is never reached because it is never part of `text`.
543
+ def strip_findings(text)
544
+ lines = text.to_s.each_line.to_a
545
+ out = []
546
+ i = 0
547
+ while i < lines.length
548
+ m = lines[i].match(/\A(#+)[ \t]+(.*?)\s*\z/)
549
+ if m && m[1].length == 3 && m[2] =~ /\AFindings\b/i
550
+ i += 1
551
+ while i < lines.length
552
+ m2 = lines[i].match(/\A(#+)[ \t]+/)
553
+ break if m2 && m2[1].length <= 3
554
+
555
+ i += 1
556
+ end
557
+ next
558
+ end
559
+ out << lines[i]
560
+ i += 1
561
+ end
562
+ out.join
563
+ end
564
+
565
+ # ===========================================================================
566
+ # n3: assembly, the budget, and the packet's identity
567
+ # ===========================================================================
568
+
569
+ # Block labels/sources for the wrapped (data) blocks, spec D3.
570
+ LEDGER_LABEL = "ledger"
571
+ RECORD_LABEL = "record"
572
+ HOP_LABEL = "knowledge hop"
573
+
574
+ # The whole "ledger" data block (spec block 2): the node's own transition
575
+ # lines, its predecessors' evidence, its lease, and any landed commits
576
+ # after a reclaim.
577
+ def full_ledger_text(intent_dir:, node:, files:, holder:, expires:, model:, repo_dir:, git_runner:, entries: nil)
578
+ entries ||= NodeLedger.entries(savepoint_path(intent_dir))
579
+ landed = landed_commits_block(intent_dir: intent_dir, node: node, files: files, repo_dir: repo_dir,
580
+ git_runner: git_runner, entries: entries)
581
+ parts = [
582
+ "### Transitions",
583
+ ledger_lines_block(intent_dir: intent_dir, node: node, entries: entries),
584
+ "",
585
+ "### Predecessors",
586
+ predecessor_block(intent_dir: intent_dir, node: node, entries: entries),
587
+ "",
588
+ "### Lease",
589
+ lease_block(intent_dir: intent_dir, node: node, holder: holder, expires: expires, model: model,
590
+ entries: entries),
591
+ ]
592
+ if landed
593
+ parts << ""
594
+ parts << "### Landed commits"
595
+ parts << landed
596
+ end
597
+ parts.join("\n")
598
+ end
599
+
600
+ # The record's Intent/Decisions/Insights rendered as one payload, over
601
+ # whatever (possibly already-cut) decisions/insights arrays the cut ladder
602
+ # is currently holding.
603
+ def render_record_text(intent:, decisions:, insights:)
604
+ parts = ["## Intent", intent.to_s.strip, ""]
605
+ parts << "## Decisions"
606
+ parts << (decisions.empty? ? "(none)" : decisions.join.rstrip)
607
+ parts << ""
608
+ parts << "## Insights"
609
+ parts << (insights.empty? ? "(none)" : insights.join.rstrip)
610
+ "#{parts.join("\n")}\n"
611
+ end
612
+
613
+ # One packet, node and where-to-work as plain instruction text, ledger,
614
+ # record and (when present) hop wrapped as labeled data sharing ONE
615
+ # boundary token computed over their raw payloads (spec D2-D4, matrix
616
+ # 3.1-3.3).
617
+ #
618
+ # Blocks 1 and 5 are raw-interpolated (spec D3: instruction, not data), but
619
+ # that trust does not reach a `release.verify` a project.yml can carry
620
+ # (post-execution review finding A2): a line that is, on its own, a
621
+ # complete data marker is disarmed by `PacketWrapper.neutralize_marker_lines`
622
+ # before it ever reaches the packet, so a forged marker cannot open a
623
+ # block outside the wrapper's own boundary. `record_source`/`hop_source`
624
+ # (finding C15) are the record's and the hop sources' real store-relative
625
+ # paths, never the placeholder label "record"/"sources".
626
+ def render_packet(node_text:, ledger_text:, intent_text:, decisions:, insights:, hop:, where_text:,
627
+ record_source: "record", hop_source: "sources")
628
+ record_text = render_record_text(intent: intent_text, decisions: decisions, insights: insights)
629
+ hop_text = hop && hop[:text]
630
+
631
+ payloads = [ledger_text, record_text]
632
+ payloads << hop_text if hop_text
633
+ token = PacketWrapper.boundary_token(payloads)
634
+
635
+ node_text_safe = PacketWrapper.neutralize_marker_lines(node_text.to_s)
636
+ where_text_safe = PacketWrapper.neutralize_marker_lines(where_text.to_s)
637
+
638
+ # Normalized through `attr_safe` exactly as `wrap` normalizes the marker
639
+ # attributes it writes (post-execution review finding A2's integrity
640
+ # check): comparing the raw `record_source`/`hop_source` against what
641
+ # `unwrap` reads back off the rendered marker line raised on every nil
642
+ # source, because `attr_safe(nil)` renders as `""`, not `"nil".to_s`.
643
+ wrapped_specs = [
644
+ { label: PacketWrapper.attr_safe(LEDGER_LABEL), source: PacketWrapper.attr_safe("savepoint.md") },
645
+ { label: PacketWrapper.attr_safe(RECORD_LABEL), source: PacketWrapper.attr_safe(record_source) },
646
+ ]
647
+ wrapped_specs << { label: PacketWrapper.attr_safe(HOP_LABEL), source: PacketWrapper.attr_safe(hop_source) } if hop_text
648
+
649
+ parts = [node_text_safe.rstrip, ""]
650
+ parts << PacketWrapper.wrap(ledger_text, label: LEDGER_LABEL, source: "savepoint.md", token: token).rstrip
651
+ parts << ""
652
+ parts << PacketWrapper.wrap(record_text, label: RECORD_LABEL, source: record_source, token: token).rstrip
653
+ parts << ""
654
+ if hop_text
655
+ parts << PacketWrapper.wrap(hop_text, label: HOP_LABEL, source: hop_source, token: token).rstrip
656
+ parts << ""
657
+ end
658
+ parts << where_text_safe.rstrip
659
+ rendered = "#{parts.join("\n")}\n"
660
+
661
+ assert_packet_integrity!(rendered, wrapped_specs)
662
+ rendered
663
+ end
664
+
665
+ # The trust boundary's own invariant (post-execution review finding A2):
666
+ # the finished packet must unwrap to exactly the data blocks that were
667
+ # wrapped, same count, same labels, same sources, in order. This is what
668
+ # the escaping rule and the marker-line neutralization pass are FOR, so the
669
+ # check belongs here, not only in a test that could rot independently of
670
+ # the code it is meant to guard.
671
+ def assert_packet_integrity!(rendered, wrapped_specs)
672
+ actual = PacketWrapper.unwrap(rendered).map { |b| { label: b[:label], source: b[:source] } }
673
+ return if actual == wrapped_specs
674
+
675
+ raise "node packet integrity check failed: expected #{wrapped_specs.inspect}, got #{actual.inspect}"
676
+ end
677
+ private_class_method :assert_packet_integrity!
678
+
679
+ def render_from_state(state)
680
+ render_packet(node_text: state[:node_text], ledger_text: state[:ledger_text], intent_text: state[:intent_text],
681
+ decisions: state[:decisions], insights: state[:insights], hop: state[:hop],
682
+ where_text: state[:where_text], record_source: state[:record_source] || "record",
683
+ hop_source: state[:hop_source] || "sources")
684
+ end
685
+ private_class_method :render_from_state
686
+
687
+ def estimate_rendered_tokens(rendered)
688
+ PacketWrapper.estimate_tokens(rendered)
689
+ end
690
+
691
+ # The C28 cut ladder: drop the hop whole, then cut Insights to the last
692
+ # one, then cut Decisions to the last five - applied only as far as
693
+ # needed, and only when a step actually shrinks the rendered bytes (matrix
694
+ # 3.23: a cut that would not reduce the render is skipped rather than
695
+ # counted as applied). The node, ledger and where-to-work blocks are never
696
+ # touched (matrix 3.9) because nothing here ever rewrites those keys.
697
+ def apply_cut_ladder(state, budget_tokens)
698
+ cuts = []
699
+ current = state
700
+ rendered = render_from_state(current)
701
+ tokens = estimate_rendered_tokens(rendered)
702
+ return [rendered, tokens, cuts] if tokens <= budget_tokens
703
+
704
+ if current[:hop] && current[:hop][:text]
705
+ candidate_state = current.merge(hop: { text: nil, tokens: 0 })
706
+ candidate = render_from_state(candidate_state)
707
+ if candidate.bytesize < rendered.bytesize
708
+ current, rendered = candidate_state, candidate
709
+ tokens = estimate_rendered_tokens(rendered)
710
+ cuts << :hop
711
+ end
712
+ end
713
+ return [rendered, tokens, cuts] if tokens <= budget_tokens
714
+
715
+ if current[:insights].length > 1
716
+ candidate_state = current.merge(insights: current[:insights].last(1))
717
+ candidate = render_from_state(candidate_state)
718
+ if candidate.bytesize < rendered.bytesize
719
+ current, rendered = candidate_state, candidate
720
+ tokens = estimate_rendered_tokens(rendered)
721
+ cuts << :insights
722
+ end
723
+ end
724
+ return [rendered, tokens, cuts] if tokens <= budget_tokens
725
+
726
+ if current[:decisions].length > DECISIONS_KEEP
727
+ candidate_state = current.merge(decisions: current[:decisions].last(DECISIONS_KEEP))
728
+ candidate = render_from_state(candidate_state)
729
+ if candidate.bytesize < rendered.bytesize
730
+ current, rendered = candidate_state, candidate
731
+ tokens = estimate_rendered_tokens(rendered)
732
+ cuts << :decisions
733
+ end
734
+ end
735
+
736
+ [rendered, tokens, cuts]
737
+ end
738
+ private_class_method :apply_cut_ladder
739
+
740
+ # Which never-cut-or-already-at-floor block is largest, so a refusal names
741
+ # what to shorten (matrix 3.24) rather than just saying "too big".
742
+ def name_oversized_block(state)
743
+ record_text = render_record_text(intent: state[:intent_text], decisions: state[:decisions],
744
+ insights: state[:insights])
745
+ candidates = {
746
+ "node" => state[:node_text],
747
+ "ledger" => state[:ledger_text],
748
+ "record" => record_text,
749
+ "where to work" => state[:where_text],
750
+ }
751
+ name, text = candidates.max_by { |_, t| PacketWrapper.estimate_tokens(t.to_s) }
752
+ [name, PacketWrapper.estimate_tokens(text.to_s)]
753
+ end
754
+ private_class_method :name_oversized_block
755
+
756
+ def lease_flag_given?(holder)
757
+ lease_present?(holder)
758
+ end
759
+ private_class_method :lease_flag_given?
760
+
761
+ # C21: the number of `running` lines already recorded for `node`, plus one
762
+ # when a lease is being supplied by flag (a NEW dispatch), floored at 1.
763
+ # Minor 5: a torn `running` line (missing holder=/expires=/packet=/model=)
764
+ # is skipped here exactly as ReadySet.attempts_count already skips it -
765
+ # counting it desynchronizes this attempt number from the extensions file
766
+ # a live node's own `packets/<node>--a<N>.extensions` names, since that
767
+ # file is keyed by the attempt sweep computed off the SAME filtered count.
768
+ def compute_attempt_number(intent_dir:, node:, lease_flag_given:, entries: nil)
769
+ entries ||= NodeLedger.entries(savepoint_path(intent_dir))
770
+ count = entries.count { |e| !e[:torn] && e[:subject] == node.to_s && e[:state] == "running" }
771
+ [count + (lease_flag_given ? 1 : 0), 1].max
772
+ end
773
+
774
+ def packet_path(intent_dir:, node:, attempt:)
775
+ File.join(intent_dir, "packets", "#{node}--a#{attempt}.packet")
776
+ end
777
+
778
+ def summary_line(result)
779
+ "path=#{result[:path]} sha=#{result[:sha]} tokens=#{result[:tokens]} hop_tokens=#{result[:hop_tokens]} " \
780
+ "attempt=#{result[:attempt]}"
781
+ end
782
+
783
+ def running_command(intent_dir:, node:, sha:, hop_tokens:)
784
+ "node-transition #{intent_dir} --node #{node} --state running --field packet=#{sha} --field hop=#{hop_tokens}"
785
+ end
786
+
787
+ def needs_decision_command(intent_dir:, node:, question:)
788
+ escaped = question.to_s.gsub("\\", "\\\\\\\\").gsub('"', "\\\"")
789
+ "node-transition #{intent_dir} --node #{node} --state needs_decision --field question=\"#{escaped}\""
790
+ end
791
+
792
+ # Build one node's whole packet from disk (spec D1). Returns
793
+ # {ok:, exit_code:, path:, sha:, tokens:, hop_tokens:, attempt:,
794
+ # cuts_applied:, running_command:, errors:} on success, or
795
+ # {ok: false, exit_code:, errors:, needs_decision_command: (on overflow)}
796
+ # on refusal. Exit codes follow the node-transition family (spec D17): 2
797
+ # usage (unknown node), 3 unreadable/unparsable graph, node file or
798
+ # record, 4 overflow past the third cut, 5 an existing attempt whose bytes
799
+ # differ.
800
+ # M7/row 10.9: `budget_tokens: nil` (rather than DEFAULT_BUDGET_TOKENS)
801
+ # is how a caller says "no override" - the fallback below then reaches for
802
+ # the node block's OWN declared `budget:` before ever touching the
803
+ # shipped default, so a caller that never learned about a node's budget
804
+ # (the CLI, another future caller) still gets it, not just the one path
805
+ # RunnerDispatch explicitly threads it through (row 10.8).
806
+ def build(intent_dir:, node:, budget_tokens: nil, hop_tokens: DEFAULT_HOP_TOKENS,
807
+ holder: nil, expires: nil, model: nil, attempt: nil, out: nil, force: false,
808
+ renamer: File.method(:rename), git_runner: DEFAULT_GIT_RUNNER,
809
+ worktree_reader: Arm.method(:worktree_block), project_reader: method(:default_project_reader),
810
+ call_cap: nil)
811
+ intent_dir = File.expand_path(intent_dir)
812
+
813
+ nb = node_block(intent_dir: intent_dir, node: node)
814
+ unless nb[:ok]
815
+ return { ok: false, exit_code: nb[:error_kind] == :unknown_node ? 2 : 3, errors: nb[:errors] }
816
+ end
817
+
818
+ budget_tokens = (budget_tokens || nb[:budget] || DEFAULT_BUDGET_TOKENS).to_i
819
+
820
+ record = record_block(intent_dir: intent_dir, kind: nb[:kind])
821
+ return { ok: false, exit_code: 3, errors: record[:errors] } unless record[:ok]
822
+
823
+ repo_dir = begin
824
+ info = worktree_reader.call(intent_dir: intent_dir)
825
+ info && info["code"]
826
+ rescue StandardError
827
+ nil
828
+ end
829
+
830
+ # Read the ledger once and thread it through every block that consults
831
+ # it (post-execution review finding B12): `node-transition` is a
832
+ # concurrent appending writer, so re-reading `savepoint.md` once per
833
+ # block risked a line landing mid-build and making the Transitions,
834
+ # Lease and attempt number of one packet disagree with each other.
835
+ entries = NodeLedger.entries(savepoint_path(intent_dir))
836
+
837
+ ledger_text = full_ledger_text(intent_dir: intent_dir, node: node, files: nb[:files], holder: holder,
838
+ expires: expires, model: model, repo_dir: repo_dir, git_runner: git_runner,
839
+ entries: entries)
840
+
841
+ store_dir = File.dirname(intent_dir)
842
+ sources = record_sources(intent_dir)
843
+ hop_full = hop_block(store_dir: store_dir, sources: sources, hop_tokens: hop_tokens)
844
+
845
+ # Finding A1: the stop directive belongs in block 5 (instruction)
846
+ # whenever the packet carries no lease at all, never inside block 2's
847
+ # ledger data (spec D3's self-cancellation risk).
848
+ missing_lease = lease_missing?(node: node, holder: holder, expires: expires, model: model, entries: entries)
849
+ where_text = where_to_work_block(intent_dir: intent_dir, worktree_reader: worktree_reader,
850
+ project_reader: project_reader, lease_missing: missing_lease,
851
+ call_cap: call_cap, files: nb[:files])
852
+
853
+ state = {
854
+ node_text: nb[:text], ledger_text: ledger_text, intent_text: record[:intent],
855
+ decisions: record[:decisions], insights: record[:insights], hop: hop_full, where_text: where_text,
856
+ # Finding C15: the record's and the hop sources' real store-relative
857
+ # paths, never the placeholder labels "record"/"sources".
858
+ record_source: record_source_path(intent_dir), hop_source: hop_source_paths(store_dir, sources),
859
+ }
860
+
861
+ rendered, tokens, cuts_applied = apply_cut_ladder(state, budget_tokens)
862
+
863
+ if tokens > budget_tokens
864
+ final_state = state.merge(
865
+ hop: cuts_applied.include?(:hop) ? { text: nil, tokens: 0 } : state[:hop],
866
+ insights: cuts_applied.include?(:insights) ? state[:insights].last(1) : state[:insights],
867
+ decisions: cuts_applied.include?(:decisions) ? state[:decisions].last(DECISIONS_KEEP) : state[:decisions],
868
+ )
869
+ oversized_name, oversized_tokens = name_oversized_block(final_state)
870
+ question = "packet for #{node} is #{tokens} tokens after every cut, over the #{budget_tokens}-token " \
871
+ "budget; #{oversized_name} alone is #{oversized_tokens} tokens, shorten it"
872
+ return {
873
+ ok: false, exit_code: 4, tokens: tokens,
874
+ needs_decision_command: needs_decision_command(intent_dir: intent_dir, node: node, question: question),
875
+ errors: ["overflow: #{oversized_name} is #{oversized_tokens} tokens over the #{budget_tokens}-token budget"],
876
+ }
877
+ end
878
+
879
+ attempt_n = attempt || compute_attempt_number(intent_dir: intent_dir, node: node,
880
+ lease_flag_given: lease_flag_given?(holder), entries: entries)
881
+ path = out ? File.expand_path(out) : packet_path(intent_dir: intent_dir, node: node, attempt: attempt_n)
882
+ FileUtils.mkdir_p(File.dirname(path))
883
+
884
+ if File.exist?(path)
885
+ existing = File.binread(path)
886
+ if existing == rendered
887
+ # Rebuilding an unchanged attempt is a no-op (spec D11): the file on
888
+ # disk already IS these exact bytes.
889
+ elsif force
890
+ AtomicWrite.write(path, rendered, renamer: renamer)
891
+ else
892
+ return { ok: false, exit_code: 5, errors: ["attempt file exists with different bytes: #{path}"] }
893
+ end
894
+ else
895
+ AtomicWrite.write(path, rendered, renamer: renamer)
896
+ end
897
+
898
+ sha = Digest::SHA256.hexdigest(File.binread(path))[0, 12]
899
+ hop_tokens_measured = cuts_applied.include?(:hop) ? 0 : hop_full[:tokens].to_i
900
+
901
+ {
902
+ ok: true, exit_code: 0, path: path, sha: sha, tokens: tokens, hop_tokens: hop_tokens_measured,
903
+ attempt: attempt_n, cuts_applied: cuts_applied,
904
+ running_command: running_command(intent_dir: intent_dir, node: node, sha: sha, hop_tokens: hop_tokens_measured),
905
+ errors: [],
906
+ }
907
+ end
908
+ end