@zalom/plastic 2.0.0-alpha.2 → 2.0.0-alpha.20
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.
- package/agents/plastic-enforcer.md +3 -1
- package/agents/plastic-executor.md +9 -3
- package/hooks/hooks.json +12 -0
- package/hooks/message-display +134 -0
- package/hooks/statusline +4 -27
- package/package.json +2 -2
- package/scripts/agent-report +8 -2
- package/scripts/dashboard.rb +297 -18
- package/scripts/doctor.rb +652 -5
- package/scripts/end-intent +219 -0
- package/scripts/hook-capture +25 -113
- package/scripts/hook-message-display +81 -0
- package/scripts/hook-record +12 -4
- package/scripts/hook-session-start +23 -1
- package/scripts/index-projection +74 -0
- package/scripts/intent-screen +77 -0
- package/scripts/lib/action_graph_shim.rb +277 -0
- package/scripts/lib/arm.rb +26 -1
- package/scripts/lib/atomic_write.rb +31 -0
- package/scripts/lib/core_integrity.rb +71 -0
- package/scripts/lib/dashboard_screen.rb +40 -0
- package/scripts/lib/day_summary.rb +7 -2
- package/scripts/lib/doctor_core.rb +101 -5
- package/scripts/lib/doctor_session_ledger.rb +52 -0
- package/scripts/lib/graph_edges.rb +121 -0
- package/scripts/lib/graph_file.rb +246 -0
- package/scripts/lib/graph_tree.rb +98 -0
- package/scripts/lib/guarded_append.rb +155 -0
- package/scripts/lib/hook_registry.rb +14 -0
- package/scripts/lib/hook_replay.rb +211 -0
- package/scripts/lib/index_projection.rb +201 -0
- package/scripts/lib/installer_core.rb +131 -4
- package/scripts/lib/intent_screen.rb +309 -0
- package/scripts/lib/intent_screen_ansi.rb +262 -0
- package/scripts/lib/message_display.rb +586 -0
- package/scripts/lib/node_file.rb +214 -0
- package/scripts/lib/node_ids.rb +99 -0
- package/scripts/lib/node_ledger.rb +377 -0
- package/scripts/lib/node_packet.rb +886 -0
- package/scripts/lib/node_return.rb +199 -0
- package/scripts/lib/node_worktree.rb +337 -0
- package/scripts/lib/outcome_report.rb +440 -0
- package/scripts/lib/packet_wrapper.rb +132 -0
- package/scripts/lib/ready_set.rb +462 -0
- package/scripts/lib/release_guard.rb +16 -0
- package/scripts/lib/report_screen.rb +1967 -0
- package/scripts/lib/roadmap_graph.rb +210 -0
- package/scripts/lib/roadmap_migration.rb +95 -0
- package/scripts/lib/roadmap_queue.rb +155 -5
- package/scripts/lib/roadmap_render.rb +150 -0
- package/scripts/lib/roadmap_savepoint.rb +62 -12
- package/scripts/lib/runner_absorb.rb +620 -0
- package/scripts/lib/runner_answer.rb +206 -0
- package/scripts/lib/runner_core.rb +194 -0
- package/scripts/lib/runner_dispatch.rb +482 -0
- package/scripts/lib/runner_policy.rb +142 -0
- package/scripts/lib/runner_proposals.rb +254 -0
- package/scripts/lib/runner_rewind.rb +201 -0
- package/scripts/lib/runner_sweep.rb +231 -0
- package/scripts/lib/savepoint.rb +149 -12
- package/scripts/lib/screen_paint.rb +555 -0
- package/scripts/lib/screens/dashboard.rb +20 -0
- package/scripts/lib/screens/plan.rb +18 -0
- package/scripts/lib/screens/roadmap.rb +15 -0
- package/scripts/lib/session_git.rb +49 -18
- package/scripts/lib/session_ledger.rb +128 -0
- package/scripts/lib/verify_intent.rb +33 -0
- package/scripts/lib/work_graph_validator.rb +201 -0
- package/scripts/node-packet +92 -0
- package/scripts/node-transition +291 -0
- package/scripts/outcome-report +74 -0
- package/scripts/plastic-lock +8 -1
- package/scripts/ready-set +126 -0
- package/scripts/release-check +118 -0
- package/scripts/report-screen +281 -0
- package/scripts/roadmap-graph +119 -0
- package/scripts/roadmap-savepoint +7 -0
- package/scripts/runner +392 -0
- package/scripts/savepoint-note +69 -0
- package/scripts/spawn-preamble +9 -2
- package/scripts/validate-work-graph +39 -0
- package/scripts/verify-intent +2 -1
- package/skills/auto/SKILL.md +24 -17
- package/skills/auto/references/human-report-contract.md +136 -54
- package/skills/conventions/references/locks-and-worktrees.md +12 -0
- package/skills/dashboard/SKILL.md +13 -2
- package/skills/dashboard/templates/dashboard-global.md +1 -1
- package/skills/dashboard/templates/dashboard-project.md +2 -2
- package/skills/doctor/SKILL.md +10 -4
- package/skills/intent-continuing/SKILL.md +51 -41
- package/skills/intent-continuing/references/board-fill.md +9 -0
- package/skills/intent-continuing/references/boarding-matrix.md +6 -5
- package/skills/intent-continuing/references/context-management.md +1 -1
- package/skills/intent-ending/SKILL.md +36 -16
- package/skills/intent-executing/SKILL.md +17 -5
- package/skills/intent-executing/implementer-prompt.md +6 -1
- package/skills/intent-speccing/SKILL.md +7 -4
- package/skills/releasing/SKILL.md +39 -0
- package/skills/releasing/references/promotion-and-tagging.md +10 -6
- package/skills/releasing/references/release-lines.md +1 -1
- package/skills/roadmap/SKILL.md +26 -0
- package/skills/roadmap/references/file-format.md +10 -0
- package/templates/dashboard-screen.md +22 -0
- package/templates/display-fixture.md +21 -0
- package/templates/graph.md +16 -0
- package/templates/intent-screen.md +17 -0
- package/templates/node-decision.md +11 -0
- package/templates/node-research.md +11 -0
- package/templates/node-verify.md +13 -0
- package/templates/node-work.md +22 -0
- package/templates/outcome.md +19 -1
- package/templates/report-plan.md +15 -0
- package/templates/report-roadmap-delivered.md +10 -0
- package/templates/report-roadmap-plan.md +9 -0
- package/templates/report-roadmap-state.md +9 -0
- package/templates/report-state.md +11 -0
- package/templates/roadmap.md +13 -0
|
@@ -0,0 +1,886 @@
|
|
|
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
|
+
# Minor 5: a torn `running` line (missing holder=/expires=/packet=/model=)
|
|
744
|
+
# is skipped here exactly as ReadySet.attempts_count already skips it -
|
|
745
|
+
# counting it desynchronizes this attempt number from the extensions file
|
|
746
|
+
# a live node's own `packets/<node>--a<N>.extensions` names, since that
|
|
747
|
+
# file is keyed by the attempt sweep computed off the SAME filtered count.
|
|
748
|
+
def compute_attempt_number(intent_dir:, node:, lease_flag_given:, entries: nil)
|
|
749
|
+
entries ||= NodeLedger.entries(savepoint_path(intent_dir))
|
|
750
|
+
count = entries.count { |e| !e[:torn] && e[:subject] == node.to_s && e[:state] == "running" }
|
|
751
|
+
[count + (lease_flag_given ? 1 : 0), 1].max
|
|
752
|
+
end
|
|
753
|
+
|
|
754
|
+
def packet_path(intent_dir:, node:, attempt:)
|
|
755
|
+
File.join(intent_dir, "packets", "#{node}--a#{attempt}.packet")
|
|
756
|
+
end
|
|
757
|
+
|
|
758
|
+
def summary_line(result)
|
|
759
|
+
"path=#{result[:path]} sha=#{result[:sha]} tokens=#{result[:tokens]} hop_tokens=#{result[:hop_tokens]} " \
|
|
760
|
+
"attempt=#{result[:attempt]}"
|
|
761
|
+
end
|
|
762
|
+
|
|
763
|
+
def running_command(intent_dir:, node:, sha:, hop_tokens:)
|
|
764
|
+
"node-transition #{intent_dir} --node #{node} --state running --field packet=#{sha} --field hop=#{hop_tokens}"
|
|
765
|
+
end
|
|
766
|
+
|
|
767
|
+
def needs_decision_command(intent_dir:, node:, question:)
|
|
768
|
+
escaped = question.to_s.gsub("\\", "\\\\\\\\").gsub('"', "\\\"")
|
|
769
|
+
"node-transition #{intent_dir} --node #{node} --state needs_decision --field question=\"#{escaped}\""
|
|
770
|
+
end
|
|
771
|
+
|
|
772
|
+
# Build one node's whole packet from disk (spec D1). Returns
|
|
773
|
+
# {ok:, exit_code:, path:, sha:, tokens:, hop_tokens:, attempt:,
|
|
774
|
+
# cuts_applied:, running_command:, errors:} on success, or
|
|
775
|
+
# {ok: false, exit_code:, errors:, needs_decision_command: (on overflow)}
|
|
776
|
+
# on refusal. Exit codes follow the node-transition family (spec D17): 2
|
|
777
|
+
# usage (unknown node), 3 unreadable/unparsable graph, node file or
|
|
778
|
+
# record, 4 overflow past the third cut, 5 an existing attempt whose bytes
|
|
779
|
+
# differ.
|
|
780
|
+
# M7/row 10.9: `budget_tokens: nil` (rather than DEFAULT_BUDGET_TOKENS)
|
|
781
|
+
# is how a caller says "no override" - the fallback below then reaches for
|
|
782
|
+
# the node block's OWN declared `budget:` before ever touching the
|
|
783
|
+
# shipped default, so a caller that never learned about a node's budget
|
|
784
|
+
# (the CLI, another future caller) still gets it, not just the one path
|
|
785
|
+
# RunnerDispatch explicitly threads it through (row 10.8).
|
|
786
|
+
def build(intent_dir:, node:, budget_tokens: nil, hop_tokens: DEFAULT_HOP_TOKENS,
|
|
787
|
+
holder: nil, expires: nil, model: nil, attempt: nil, out: nil, force: false,
|
|
788
|
+
renamer: File.method(:rename), git_runner: DEFAULT_GIT_RUNNER,
|
|
789
|
+
worktree_reader: Arm.method(:worktree_block), project_reader: method(:default_project_reader))
|
|
790
|
+
intent_dir = File.expand_path(intent_dir)
|
|
791
|
+
|
|
792
|
+
nb = node_block(intent_dir: intent_dir, node: node)
|
|
793
|
+
unless nb[:ok]
|
|
794
|
+
return { ok: false, exit_code: nb[:error_kind] == :unknown_node ? 2 : 3, errors: nb[:errors] }
|
|
795
|
+
end
|
|
796
|
+
|
|
797
|
+
budget_tokens = (budget_tokens || nb[:budget] || DEFAULT_BUDGET_TOKENS).to_i
|
|
798
|
+
|
|
799
|
+
record = record_block(intent_dir: intent_dir, kind: nb[:kind])
|
|
800
|
+
return { ok: false, exit_code: 3, errors: record[:errors] } unless record[:ok]
|
|
801
|
+
|
|
802
|
+
repo_dir = begin
|
|
803
|
+
info = worktree_reader.call(intent_dir: intent_dir)
|
|
804
|
+
info && info["code"]
|
|
805
|
+
rescue StandardError
|
|
806
|
+
nil
|
|
807
|
+
end
|
|
808
|
+
|
|
809
|
+
# Read the ledger once and thread it through every block that consults
|
|
810
|
+
# it (post-execution review finding B12): `node-transition` is a
|
|
811
|
+
# concurrent appending writer, so re-reading `savepoint.md` once per
|
|
812
|
+
# block risked a line landing mid-build and making the Transitions,
|
|
813
|
+
# Lease and attempt number of one packet disagree with each other.
|
|
814
|
+
entries = NodeLedger.entries(savepoint_path(intent_dir))
|
|
815
|
+
|
|
816
|
+
ledger_text = full_ledger_text(intent_dir: intent_dir, node: node, files: nb[:files], holder: holder,
|
|
817
|
+
expires: expires, model: model, repo_dir: repo_dir, git_runner: git_runner,
|
|
818
|
+
entries: entries)
|
|
819
|
+
|
|
820
|
+
store_dir = File.dirname(intent_dir)
|
|
821
|
+
sources = record_sources(intent_dir)
|
|
822
|
+
hop_full = hop_block(store_dir: store_dir, sources: sources, hop_tokens: hop_tokens)
|
|
823
|
+
|
|
824
|
+
# Finding A1: the stop directive belongs in block 5 (instruction)
|
|
825
|
+
# whenever the packet carries no lease at all, never inside block 2's
|
|
826
|
+
# ledger data (spec D3's self-cancellation risk).
|
|
827
|
+
missing_lease = lease_missing?(node: node, holder: holder, expires: expires, model: model, entries: entries)
|
|
828
|
+
where_text = where_to_work_block(intent_dir: intent_dir, worktree_reader: worktree_reader,
|
|
829
|
+
project_reader: project_reader, lease_missing: missing_lease)
|
|
830
|
+
|
|
831
|
+
state = {
|
|
832
|
+
node_text: nb[:text], ledger_text: ledger_text, intent_text: record[:intent],
|
|
833
|
+
decisions: record[:decisions], insights: record[:insights], hop: hop_full, where_text: where_text,
|
|
834
|
+
# Finding C15: the record's and the hop sources' real store-relative
|
|
835
|
+
# paths, never the placeholder labels "record"/"sources".
|
|
836
|
+
record_source: record_source_path(intent_dir), hop_source: hop_source_paths(store_dir, sources),
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
rendered, tokens, cuts_applied = apply_cut_ladder(state, budget_tokens)
|
|
840
|
+
|
|
841
|
+
if tokens > budget_tokens
|
|
842
|
+
final_state = state.merge(
|
|
843
|
+
hop: cuts_applied.include?(:hop) ? { text: nil, tokens: 0 } : state[:hop],
|
|
844
|
+
insights: cuts_applied.include?(:insights) ? state[:insights].last(1) : state[:insights],
|
|
845
|
+
decisions: cuts_applied.include?(:decisions) ? state[:decisions].last(DECISIONS_KEEP) : state[:decisions],
|
|
846
|
+
)
|
|
847
|
+
oversized_name, oversized_tokens = name_oversized_block(final_state)
|
|
848
|
+
question = "packet for #{node} is #{tokens} tokens after every cut, over the #{budget_tokens}-token " \
|
|
849
|
+
"budget; #{oversized_name} alone is #{oversized_tokens} tokens, shorten it"
|
|
850
|
+
return {
|
|
851
|
+
ok: false, exit_code: 4, tokens: tokens,
|
|
852
|
+
needs_decision_command: needs_decision_command(intent_dir: intent_dir, node: node, question: question),
|
|
853
|
+
errors: ["overflow: #{oversized_name} is #{oversized_tokens} tokens over the #{budget_tokens}-token budget"],
|
|
854
|
+
}
|
|
855
|
+
end
|
|
856
|
+
|
|
857
|
+
attempt_n = attempt || compute_attempt_number(intent_dir: intent_dir, node: node,
|
|
858
|
+
lease_flag_given: lease_flag_given?(holder), entries: entries)
|
|
859
|
+
path = out ? File.expand_path(out) : packet_path(intent_dir: intent_dir, node: node, attempt: attempt_n)
|
|
860
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
861
|
+
|
|
862
|
+
if File.exist?(path)
|
|
863
|
+
existing = File.binread(path)
|
|
864
|
+
if existing == rendered
|
|
865
|
+
# Rebuilding an unchanged attempt is a no-op (spec D11): the file on
|
|
866
|
+
# disk already IS these exact bytes.
|
|
867
|
+
elsif force
|
|
868
|
+
AtomicWrite.write(path, rendered, renamer: renamer)
|
|
869
|
+
else
|
|
870
|
+
return { ok: false, exit_code: 5, errors: ["attempt file exists with different bytes: #{path}"] }
|
|
871
|
+
end
|
|
872
|
+
else
|
|
873
|
+
AtomicWrite.write(path, rendered, renamer: renamer)
|
|
874
|
+
end
|
|
875
|
+
|
|
876
|
+
sha = Digest::SHA256.hexdigest(File.binread(path))[0, 12]
|
|
877
|
+
hop_tokens_measured = cuts_applied.include?(:hop) ? 0 : hop_full[:tokens].to_i
|
|
878
|
+
|
|
879
|
+
{
|
|
880
|
+
ok: true, exit_code: 0, path: path, sha: sha, tokens: tokens, hop_tokens: hop_tokens_measured,
|
|
881
|
+
attempt: attempt_n, cuts_applied: cuts_applied,
|
|
882
|
+
running_command: running_command(intent_dir: intent_dir, node: node, sha: sha, hop_tokens: hop_tokens_measured),
|
|
883
|
+
errors: [],
|
|
884
|
+
}
|
|
885
|
+
end
|
|
886
|
+
end
|