@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.
- package/agents/plastic-enforcer.md +3 -1
- package/agents/plastic-executor.md +9 -3
- package/bin/test +24 -4
- package/hooks/call-budget +4 -0
- package/hooks/hooks.json +24 -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-call-budget +222 -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 +45 -7
- 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/compact_instructions.rb +5 -5
- 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 +102 -5
- package/scripts/lib/doctor_session_ledger.rb +52 -0
- package/scripts/lib/graph_edges.rb +137 -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 +28 -2
- package/scripts/lib/hook_replay.rb +211 -0
- package/scripts/lib/index_projection.rb +201 -0
- package/scripts/lib/installer_core.rb +141 -6
- 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/meter_watch.rb +179 -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 +908 -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 +506 -0
- package/scripts/lib/runner_policy.rb +173 -0
- package/scripts/lib/runner_proposals.rb +275 -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/session_usage.rb +190 -0
- package/scripts/lib/verify_intent.rb +33 -0
- package/scripts/lib/work_graph_validator.rb +201 -0
- package/scripts/meter-watch +57 -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/read-config +3 -3
- 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 +397 -0
- package/scripts/savepoint-note +69 -0
- package/scripts/session-usage +56 -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 +41 -34
- 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 +21 -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/config.yml +3 -3
- 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,275 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require_relative "node_file"
|
|
6
|
+
require_relative "node_ids"
|
|
7
|
+
require_relative "graph_edges"
|
|
8
|
+
require_relative "graph_file"
|
|
9
|
+
require_relative "atomic_write"
|
|
10
|
+
require_relative "ready_set"
|
|
11
|
+
require_relative "node_ledger"
|
|
12
|
+
require_relative "savepoint"
|
|
13
|
+
require_relative "work_graph_validator"
|
|
14
|
+
|
|
15
|
+
# RunnerProposals (intent 340, G7, n6): accepts or refuses what an executor
|
|
16
|
+
# proposed in its return (327 D15, D28, D30). The runner mints every node id;
|
|
17
|
+
# an executor never sees or picks one. A proposed node is scaffolded from its
|
|
18
|
+
# kind's template, its id substituted everywhere the template hard-codes its
|
|
19
|
+
# own placeholder (`n1`, `v1`, `d1`, `r1` - the frontmatter key, the H1, and
|
|
20
|
+
# the failure-mode matrix heading), and appended to graph.md's ## Graph
|
|
21
|
+
# section as a fresh, undispatched line - no ledger line at all, which reads
|
|
22
|
+
# as "planned" by construction. A proposed edge is accepted only when both
|
|
23
|
+
# endpoints exist (among the already-declared nodes or a node this very call
|
|
24
|
+
# is minting), its head is not `running`, and the resulting graph stays
|
|
25
|
+
# acyclic (D30); a refusal writes one ledger comment naming which of the
|
|
26
|
+
# three failed.
|
|
27
|
+
#
|
|
28
|
+
# One call, one all-or-nothing write (D15's "nothing is partially written"):
|
|
29
|
+
# every proposed node and edge is validated BEFORE anything touches disk. A
|
|
30
|
+
# single rejected edge refuses the WHOLE call, leaving graph.md and nodes/
|
|
31
|
+
# byte-identical to how #accept found them, and burning no minted id (an id
|
|
32
|
+
# is only ever consumed once a node FILE actually exists; NodeIds.taken never
|
|
33
|
+
# sees one this call abandoned).
|
|
34
|
+
#
|
|
35
|
+
# Pure and dependency-injected: the full validator re-run (327 D17's "a mid-
|
|
36
|
+
# run scaffold is checked, not merely accepted"), the templates directory,
|
|
37
|
+
# the clock and the rename call are all injectable keyword arguments with
|
|
38
|
+
# real defaults, so a test never touches a real ~/.plastic install.
|
|
39
|
+
module RunnerProposals
|
|
40
|
+
module_function
|
|
41
|
+
|
|
42
|
+
KIND_TEMPLATES = {
|
|
43
|
+
"work" => "node-work.md",
|
|
44
|
+
"verify" => "node-verify.md",
|
|
45
|
+
"decision" => "node-decision.md",
|
|
46
|
+
"research" => "node-research.md",
|
|
47
|
+
}.freeze
|
|
48
|
+
|
|
49
|
+
REVIEW_FIX_CAP = 2
|
|
50
|
+
|
|
51
|
+
# accept(context, proposer:, proposed_nodes:, proposed_edges:, now:,
|
|
52
|
+
# validator:, templates_dir:, renamer:) -> {ok:, minted:, validator:,
|
|
53
|
+
# errors:}. `proposer` names the node whose return carried these proposals
|
|
54
|
+
# - it is used only to attribute the refusal comment; the runner itself
|
|
55
|
+
# decides everything about the proposal's fate.
|
|
56
|
+
def accept(context, proposer:, proposed_nodes: [], proposed_edges: [], now: Time.now,
|
|
57
|
+
validator: WorkGraphValidator.method(:validate), templates_dir: nil,
|
|
58
|
+
renamer: File.method(:rename))
|
|
59
|
+
intent_dir = context.intent_dir
|
|
60
|
+
graph_path = File.join(intent_dir, "graph.md")
|
|
61
|
+
templates_root = templates_dir || File.join(context.plastic_home.to_s, "templates")
|
|
62
|
+
|
|
63
|
+
# v2 NEW-5/row 11.8: `ReadySet.load_graph` documents itself as never
|
|
64
|
+
# raising, but that guarantee is only as good as the parser underneath
|
|
65
|
+
# it - a `graph.md` carrying invalid UTF-8 bytes raises `ArgumentError`
|
|
66
|
+
# out of `GraphFile`'s own fence-line scan before `load_graph` ever gets
|
|
67
|
+
# a chance to catch it. M4 made this call reachable straight from
|
|
68
|
+
# `RunnerAbsorb#absorb`, so a `--return` carrying a proposal against a
|
|
69
|
+
# malformed graph used to die with a raw stack trace instead of a plain
|
|
70
|
+
# refusal.
|
|
71
|
+
loaded = safe_load_graph(intent_dir)
|
|
72
|
+
return refuse(intent_dir, proposer, "graph.md is unreadable: #{loaded[:errors].join('; ')}", now) if loaded[:unreadable]
|
|
73
|
+
|
|
74
|
+
declared = loaded[:edges].keys
|
|
75
|
+
status_map = NodeLedger.status_from_content(read_savepoint(intent_dir))
|
|
76
|
+
|
|
77
|
+
mint_pool = NodeIds.taken(intent_dir).dup
|
|
78
|
+
node_specs = []
|
|
79
|
+
|
|
80
|
+
Array(proposed_nodes).each do |raw|
|
|
81
|
+
spec = stringify(raw)
|
|
82
|
+
kind = spec["kind"].to_s
|
|
83
|
+
template_name = KIND_TEMPLATES[kind]
|
|
84
|
+
unless template_name
|
|
85
|
+
return refuse(intent_dir, proposer, "proposed node kind #{kind.inspect} has no template", now)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
template_path = File.join(templates_root, template_name)
|
|
89
|
+
unless File.exist?(template_path)
|
|
90
|
+
return refuse(intent_dir, proposer, "missing template for kind #{kind.inspect} at #{template_path}", now)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
id = NodeFile.mint_id(kind, mint_pool)
|
|
94
|
+
mint_pool << id
|
|
95
|
+
node_specs << {
|
|
96
|
+
id: id, kind: kind, template_path: template_path,
|
|
97
|
+
needs: Array(spec["needs"]).map(&:to_s),
|
|
98
|
+
files: spec["files"].nil? ? nil : Array(spec["files"]),
|
|
99
|
+
budget: spec["budget"],
|
|
100
|
+
}
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
trial_edges = loaded[:edges].dup
|
|
104
|
+
node_specs.each { |s| trial_edges[s[:id]] = s[:needs] }
|
|
105
|
+
known_ids = declared + node_specs.map { |s| s[:id] }
|
|
106
|
+
|
|
107
|
+
edge_specs = []
|
|
108
|
+
Array(proposed_edges).each do |raw|
|
|
109
|
+
e = stringify(raw)
|
|
110
|
+
from = e["from"].to_s
|
|
111
|
+
to = e["to"].to_s
|
|
112
|
+
|
|
113
|
+
unless known_ids.include?(from) && known_ids.include?(to)
|
|
114
|
+
return refuse(intent_dir, proposer,
|
|
115
|
+
"edge #{from}->#{to} refused (endpoint_unknown): both ends must be a declared or proposed node", now)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
if status_map.fetch(from, "planned") == "running"
|
|
119
|
+
return refuse(intent_dir, proposer,
|
|
120
|
+
"edge #{from}->#{to} refused (head_running): #{from} is currently running", now)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
trial = trial_edges.dup
|
|
124
|
+
trial[from] = (trial[from] || []) + [to]
|
|
125
|
+
if GraphEdges.cycle(trial)
|
|
126
|
+
return refuse(intent_dir, proposer,
|
|
127
|
+
"edge #{from}->#{to} refused (would_cycle): would make the graph cyclic", now)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
trial_edges = trial
|
|
131
|
+
edge_specs << { from: from, to: to }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
trial_nodes = loaded[:nodes].merge(node_specs.to_h { |s| [s[:id], { kind: s[:kind] }] })
|
|
135
|
+
existing_fixes = review_fix_count(loaded[:edges], loaded[:nodes])
|
|
136
|
+
trial_fixes = review_fix_count(trial_edges, trial_nodes)
|
|
137
|
+
if trial_fixes > [existing_fixes, REVIEW_FIX_CAP].max
|
|
138
|
+
return refuse(intent_dir, proposer,
|
|
139
|
+
"proposal refused (review_fix_cap): would make #{trial_fixes} review fixes, the cap is #{REVIEW_FIX_CAP}", now)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
node_specs.each { |s| scaffold_node_file(intent_dir, s) }
|
|
143
|
+
if node_specs.any? || edge_specs.any?
|
|
144
|
+
append_to_graph(graph_path, node_specs: node_specs, edge_specs: edge_specs, renamer: renamer)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# v2 NEW-5/row 11.8: the accept itself already landed on disk by this
|
|
148
|
+
# point (327 D17's "a mid-run scaffold is checked" runs strictly AFTER
|
|
149
|
+
# the write) - a raising validator must report a broken verdict, never
|
|
150
|
+
# turn an already-successful accept into an uncaught exception.
|
|
151
|
+
{ ok: true, minted: node_specs.map { |s| s[:id] }, validator: safe_validate(validator, intent_dir), errors: [] }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# --- review fixes (355 D4) -------------------------------------------------
|
|
155
|
+
|
|
156
|
+
# review_fix_count(edges, nodes) -> how many work nodes GraphEdges.review_fixes
|
|
157
|
+
# finds, a node's kind read from its declaration and, when that is missing,
|
|
158
|
+
# from its id's NodeFile::KIND_PREFIX (343 D6).
|
|
159
|
+
def review_fix_count(edges, nodes)
|
|
160
|
+
ids = (edges.keys + edges.values.flatten).uniq
|
|
161
|
+
kinds = ids.to_h { |id| [id, (nodes[id] || {})[:kind] || NodeFile::KIND_PREFIX.key(id.to_s[/\A[a-z]+/])] }
|
|
162
|
+
GraphEdges.review_fixes(edges, kinds).length
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# --- node scaffolding --------------------------------------------------
|
|
166
|
+
|
|
167
|
+
# A template's own placeholder id ("n1", "v1", ...) is replaced only as a
|
|
168
|
+
# whole token, so minting past 9 (a proposed "n10") never mangles a
|
|
169
|
+
# coincidental substring match.
|
|
170
|
+
def substitute_id(text, old_id, new_id)
|
|
171
|
+
text.to_s.gsub(/(?<![A-Za-z0-9_-])#{Regexp.escape(old_id.to_s)}(?![A-Za-z0-9_-])/, new_id.to_s)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def scaffold_node_file(intent_dir, spec)
|
|
175
|
+
parsed_template = NodeFile.parse(spec[:template_path])
|
|
176
|
+
old_id = parsed_template[:node]
|
|
177
|
+
raw = File.read(spec[:template_path])
|
|
178
|
+
parts = raw.split("---", 3)
|
|
179
|
+
body = substitute_id(parts[2].to_s, old_id, spec[:id])
|
|
180
|
+
|
|
181
|
+
files = spec[:files].nil? ? parsed_template[:files] : spec[:files]
|
|
182
|
+
budget = spec[:budget].nil? ? parsed_template[:budget] : spec[:budget]
|
|
183
|
+
|
|
184
|
+
frontmatter = "---\nnode: #{spec[:id]}\nkind: #{spec[:kind]}\nfiles: [#{Array(files).join(', ')}]\nbudget: #{budget}\n---"
|
|
185
|
+
path = File.join(intent_dir, "nodes", "#{spec[:id]}.md")
|
|
186
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
187
|
+
File.write(path, "#{frontmatter}#{body}")
|
|
188
|
+
end
|
|
189
|
+
private_class_method :scaffold_node_file
|
|
190
|
+
|
|
191
|
+
# --- graph.md editing ----------------------------------------------------
|
|
192
|
+
|
|
193
|
+
# Appends one "- <id> needs <targets>" line per minted node, then applies
|
|
194
|
+
# each accepted edge onto the line (existing or just-appended) whose id
|
|
195
|
+
# matches its `from`, through GraphFile's own public section helpers
|
|
196
|
+
# (section_bounds, section_body, replace_or_append_section - none of them
|
|
197
|
+
# private_class_method'd) so this stays a plain text edit, never a second
|
|
198
|
+
# bespoke parser.
|
|
199
|
+
def append_to_graph(graph_path, node_specs:, edge_specs:, renamer:)
|
|
200
|
+
content = File.read(graph_path)
|
|
201
|
+
bounds = GraphFile.section_bounds(content, "## Graph")
|
|
202
|
+
return unless bounds
|
|
203
|
+
|
|
204
|
+
body = GraphFile.section_body(content, "## Graph").to_s
|
|
205
|
+
lines = body.split("\n")
|
|
206
|
+
|
|
207
|
+
node_specs.each do |s|
|
|
208
|
+
rendered = s[:needs].empty? ? "nothing" : s[:needs].join(" ")
|
|
209
|
+
lines << "- #{s[:id]} needs #{rendered}"
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
edge_specs.each do |e|
|
|
213
|
+
idx = lines.index { |l| l.strip.match?(/\A-\s*#{Regexp.escape(e[:from])}\s+needs\b/) }
|
|
214
|
+
next unless idx
|
|
215
|
+
|
|
216
|
+
stripped = lines[idx].strip
|
|
217
|
+
m = stripped.match(/\A-\s*(\S+)\s+needs\s+(.*)\z/)
|
|
218
|
+
targets = m[2].to_s.strip.split(/\s+/)
|
|
219
|
+
targets = [] if targets == ["nothing"]
|
|
220
|
+
targets << e[:to] unless targets.include?(e[:to])
|
|
221
|
+
lines[idx] = "- #{e[:from]} needs #{targets.empty? ? 'nothing' : targets.join(' ')}"
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
new_body = lines.join("\n")
|
|
225
|
+
new_content = GraphFile.replace_or_append_section(content, "## Graph", new_body)
|
|
226
|
+
AtomicWrite.write(graph_path, new_content, renamer: renamer)
|
|
227
|
+
end
|
|
228
|
+
private_class_method :append_to_graph
|
|
229
|
+
|
|
230
|
+
# --- refusal -------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
# One ledger comment (a plain milestone-style line through Savepoint's own
|
|
233
|
+
# public append primitive, never a state transition - a refused proposal
|
|
234
|
+
# changes nothing about the proposer's own status) naming which of the
|
|
235
|
+
# three checks failed. Dedup'd by (stage, text) like every other such line,
|
|
236
|
+
# so an identical refusal repeated verbatim across retries writes once.
|
|
237
|
+
def refuse(intent_dir, proposer, reason, now)
|
|
238
|
+
Savepoint.append_savepoint_line(intent_dir, "Proposal", "#{proposer}: #{reason}", now)
|
|
239
|
+
{ ok: false, minted: [], validator: nil, errors: [reason] }
|
|
240
|
+
end
|
|
241
|
+
private_class_method :refuse
|
|
242
|
+
|
|
243
|
+
# --- guarded re-entries into graph.md (v2 NEW-5/row 11.8) -------------------
|
|
244
|
+
|
|
245
|
+
# `unreadable: true` marks the ONE case a raise actually happened -
|
|
246
|
+
# distinct from `ReadySet.load_graph`'s own ordinary `ok: false` (a
|
|
247
|
+
# cyclic graph, a missing node file, ...), which callers here already
|
|
248
|
+
# tolerate and must keep tolerating unchanged.
|
|
249
|
+
def safe_load_graph(intent_dir)
|
|
250
|
+
ReadySet.load_graph(intent_dir)
|
|
251
|
+
rescue StandardError => e
|
|
252
|
+
{ ok: false, edges: {}, nodes: {}, errors: ["graph.md could not be read: #{e.message}"], unreadable: true }
|
|
253
|
+
end
|
|
254
|
+
private_class_method :safe_load_graph
|
|
255
|
+
|
|
256
|
+
def safe_validate(validator, intent_dir)
|
|
257
|
+
validator.call(intent_dir)
|
|
258
|
+
rescue StandardError => e
|
|
259
|
+
{ ok: false, missing: [], errors: ["graph.md could not be validated: #{e.message}"] }
|
|
260
|
+
end
|
|
261
|
+
private_class_method :safe_validate
|
|
262
|
+
|
|
263
|
+
# --- internals -------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
def stringify(hash)
|
|
266
|
+
(hash || {}).each_with_object({}) { |(k, v), memo| memo[k.to_s] = v }
|
|
267
|
+
end
|
|
268
|
+
private_class_method :stringify
|
|
269
|
+
|
|
270
|
+
def read_savepoint(intent_dir)
|
|
271
|
+
path = File.join(intent_dir.to_s, "savepoint.md")
|
|
272
|
+
File.exist?(path) ? File.read(path) : ""
|
|
273
|
+
end
|
|
274
|
+
private_class_method :read_savepoint
|
|
275
|
+
end
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require_relative "node_ledger"
|
|
6
|
+
require_relative "ready_set"
|
|
7
|
+
require_relative "node_file"
|
|
8
|
+
require_relative "node_ids"
|
|
9
|
+
require_relative "graph_file"
|
|
10
|
+
require_relative "atomic_write"
|
|
11
|
+
require_relative "runner_core"
|
|
12
|
+
require_relative "worktree"
|
|
13
|
+
require_relative "node_worktree"
|
|
14
|
+
|
|
15
|
+
# RunnerRewind (intent 340, G7, n6): resets the intent branch to a node's own
|
|
16
|
+
# recorded commit, marks every downstream node superseded (its evidence no
|
|
17
|
+
# longer stands once the code it was built on is gone), and respins the
|
|
18
|
+
# rewound node itself the same way RunnerAnswer's hard-cap path does - never
|
|
19
|
+
# a plain `planned` line, because `failed_verification_count` is counted per
|
|
20
|
+
# subject id over the WHOLE ledger and never resets; only a brand new id
|
|
21
|
+
# starts clean.
|
|
22
|
+
#
|
|
23
|
+
# Internal, confirm-gated (327 D15's rewind clause): `confirm:` must be
|
|
24
|
+
# explicitly true, and the whole intent must be quiescent (no `running` node
|
|
25
|
+
# anywhere) before a branch reset is safe to make - resetting the branch
|
|
26
|
+
# under a live executor's own worktree would move the base it is building on
|
|
27
|
+
# out from under it.
|
|
28
|
+
#
|
|
29
|
+
# Pure and dependency-injected: every git call goes through an injected
|
|
30
|
+
# `runner:` (default Worktree::ShellRunner), always `-C <path>`, never cwd;
|
|
31
|
+
# the clock and the rename call are injectable the same way every other
|
|
32
|
+
# runner_* module here already is.
|
|
33
|
+
module RunnerRewind
|
|
34
|
+
module_function
|
|
35
|
+
|
|
36
|
+
def rewind(context, node:, confirm:, now: Time.now, ledger: NodeLedger,
|
|
37
|
+
runner: Worktree::ShellRunner.new, renamer: File.method(:rename),
|
|
38
|
+
worktree: NodeWorktree)
|
|
39
|
+
node = node.to_s
|
|
40
|
+
return refusal("confirm_required") unless confirm
|
|
41
|
+
|
|
42
|
+
intent_dir = context.intent_dir
|
|
43
|
+
# M9: a second, unguarded re-parse of graph.md - RunnerCore.context's
|
|
44
|
+
# own first read is already safe, but this fresh read is not.
|
|
45
|
+
loaded = RunnerCore.safe_load_graph(intent_dir)
|
|
46
|
+
return refusal("invalid_graph", detail: Array(loaded[:errors]).join("; ")) unless loaded[:ok]
|
|
47
|
+
|
|
48
|
+
edges = loaded[:edges]
|
|
49
|
+
nodes_decl = loaded[:nodes]
|
|
50
|
+
before_content = read_savepoint(intent_dir)
|
|
51
|
+
entries = NodeLedger.entries_from_content(before_content)
|
|
52
|
+
status_map = NodeLedger.status_from_content(before_content)
|
|
53
|
+
|
|
54
|
+
return refusal("a_node_is_running") if status_map.value?("running")
|
|
55
|
+
|
|
56
|
+
commit = last_commit(entries, node)
|
|
57
|
+
return refusal("no_recorded_commit") if commit.nil? || commit.to_s.strip.empty?
|
|
58
|
+
|
|
59
|
+
return refusal("no_intent_worktree") if blank?(context.worktree)
|
|
60
|
+
|
|
61
|
+
reset = runner.run("-C", context.worktree, "reset", "--hard", commit)
|
|
62
|
+
return refusal("git_reset_failed", detail: reset.stderr.to_s.strip) unless reset.success?
|
|
63
|
+
|
|
64
|
+
downstream = downstream_of(node, edges)
|
|
65
|
+
downstream.each do |d|
|
|
66
|
+
ledger.append_transition(savepoint_path(intent_dir), subject: d, state: "superseded",
|
|
67
|
+
fields: { by: node }, now: now)
|
|
68
|
+
# M5/D7: a rewind-superseded node's evidence no longer stands once the
|
|
69
|
+
# code it was built on is gone - its worktree releases here too.
|
|
70
|
+
worktree.release(context, node: d, state: "superseded", runner: runner)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
succ_id = respin(intent_dir, node, nodes_decl, edges, ledger: ledger, now: now, renamer: renamer,
|
|
74
|
+
context: context, worktree: worktree, runner: runner)
|
|
75
|
+
|
|
76
|
+
RunnerCore.render_status(context)
|
|
77
|
+
|
|
78
|
+
{
|
|
79
|
+
ok: true, reset_to: commit, superseded: downstream, respun_to: succ_id, errors: [],
|
|
80
|
+
newly_ready: newly_ready(intent_dir, before_content: before_content, before_nodes: nodes_decl,
|
|
81
|
+
before_edges: edges),
|
|
82
|
+
}
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# --- downstream: everything that, directly or transitively, needs the
|
|
86
|
+
# rewound node - the mirror of ReadySet.critical_paths' own successors map.
|
|
87
|
+
|
|
88
|
+
def downstream_of(node, edges)
|
|
89
|
+
successors = Hash.new { |h, k| h[k] = [] }
|
|
90
|
+
edges.each { |id, targets| (targets || []).each { |t| successors[t] << id } }
|
|
91
|
+
|
|
92
|
+
seen = []
|
|
93
|
+
stack = successors[node].dup
|
|
94
|
+
until stack.empty?
|
|
95
|
+
n = stack.pop
|
|
96
|
+
next if seen.include?(n)
|
|
97
|
+
|
|
98
|
+
seen << n
|
|
99
|
+
stack.concat(successors[n] || [])
|
|
100
|
+
end
|
|
101
|
+
seen.sort
|
|
102
|
+
end
|
|
103
|
+
private_class_method :downstream_of
|
|
104
|
+
|
|
105
|
+
def last_commit(entries, node)
|
|
106
|
+
last = entries.select { |e| !e[:torn] && e[:subject] == node && e[:state] == "done" }.last
|
|
107
|
+
last && (last[:fields] || {})["commit"]
|
|
108
|
+
end
|
|
109
|
+
private_class_method :last_commit
|
|
110
|
+
|
|
111
|
+
# --- the respin (327 D22, same shape as RunnerAnswer's hard-cap path) ------
|
|
112
|
+
|
|
113
|
+
def respin(intent_dir, node, nodes_decl, edges, ledger:, now:, renamer:,
|
|
114
|
+
context:, worktree: NodeWorktree, runner: Worktree::ShellRunner.new)
|
|
115
|
+
decl = nodes_decl[node] || {}
|
|
116
|
+
kind = decl[:kind]
|
|
117
|
+
node_path = ReadySet.find_node_path(intent_dir, node)
|
|
118
|
+
parsed = NodeFile.parse(node_path)
|
|
119
|
+
|
|
120
|
+
mint_pool = NodeIds.taken(intent_dir).dup
|
|
121
|
+
succ_id = NodeFile.mint_id(kind, mint_pool)
|
|
122
|
+
|
|
123
|
+
raw = File.read(node_path)
|
|
124
|
+
parts = raw.split("---", 3)
|
|
125
|
+
body = substitute_id(parts[2].to_s, node, succ_id)
|
|
126
|
+
files = parsed[:files] || decl[:files] || []
|
|
127
|
+
budget = parsed[:budget]
|
|
128
|
+
|
|
129
|
+
frontmatter = "---\nnode: #{succ_id}\nkind: #{kind}\nfiles: [#{Array(files).join(', ')}]\nbudget: #{budget}\n---"
|
|
130
|
+
succ_path = File.join(intent_dir, "nodes", "#{succ_id}.md")
|
|
131
|
+
FileUtils.mkdir_p(File.dirname(succ_path))
|
|
132
|
+
File.write(succ_path, "#{frontmatter}#{body}")
|
|
133
|
+
|
|
134
|
+
append_graph_line(File.join(intent_dir, "graph.md"), succ_id, edges[node] || [], renamer: renamer)
|
|
135
|
+
|
|
136
|
+
ledger.append_transition(savepoint_path(intent_dir), subject: node, state: "superseded",
|
|
137
|
+
fields: { by: succ_id }, now: now)
|
|
138
|
+
|
|
139
|
+
# M5/D7: the rewound node's own worktree releases too, same as the
|
|
140
|
+
# downstream nodes above.
|
|
141
|
+
worktree.release(context, node: node, state: "superseded", runner: runner)
|
|
142
|
+
|
|
143
|
+
succ_id
|
|
144
|
+
end
|
|
145
|
+
private_class_method :respin
|
|
146
|
+
|
|
147
|
+
def append_graph_line(graph_path, id, needs, renamer:)
|
|
148
|
+
content = File.read(graph_path)
|
|
149
|
+
body = GraphFile.section_body(content, "## Graph").to_s
|
|
150
|
+
lines = body.split("\n")
|
|
151
|
+
rendered = needs.empty? ? "nothing" : needs.join(" ")
|
|
152
|
+
lines << "- #{id} needs #{rendered}"
|
|
153
|
+
new_body = lines.join("\n")
|
|
154
|
+
new_content = GraphFile.replace_or_append_section(content, "## Graph", new_body)
|
|
155
|
+
AtomicWrite.write(graph_path, new_content, renamer: renamer)
|
|
156
|
+
end
|
|
157
|
+
private_class_method :append_graph_line
|
|
158
|
+
|
|
159
|
+
def substitute_id(text, old_id, new_id)
|
|
160
|
+
text.to_s.gsub(/(?<![A-Za-z0-9_-])#{Regexp.escape(old_id.to_s)}(?![A-Za-z0-9_-])/, new_id.to_s)
|
|
161
|
+
end
|
|
162
|
+
private_class_method :substitute_id
|
|
163
|
+
|
|
164
|
+
def newly_ready(intent_dir, before_content:, before_nodes:, before_edges:)
|
|
165
|
+
after = RunnerCore.safe_load_graph(intent_dir)
|
|
166
|
+
return [] unless after[:ok]
|
|
167
|
+
|
|
168
|
+
after_content = read_savepoint(intent_dir)
|
|
169
|
+
|
|
170
|
+
after[:nodes].keys.select do |id|
|
|
171
|
+
was = before_nodes.key?(id) &&
|
|
172
|
+
ReadySet.ready?(content: before_content, subject: id, graph: { edges: before_edges },
|
|
173
|
+
nodes: before_nodes)[:ready]
|
|
174
|
+
now_ready = ReadySet.ready?(content: after_content, subject: id, graph: { edges: after[:edges] },
|
|
175
|
+
nodes: after[:nodes])[:ready]
|
|
176
|
+
now_ready && !was
|
|
177
|
+
end.sort
|
|
178
|
+
end
|
|
179
|
+
private_class_method :newly_ready
|
|
180
|
+
|
|
181
|
+
def refusal(reason, detail: nil)
|
|
182
|
+
{ ok: false, reason: reason, detail: detail, reset_to: nil, superseded: [], respun_to: nil, newly_ready: [] }
|
|
183
|
+
end
|
|
184
|
+
private_class_method :refusal
|
|
185
|
+
|
|
186
|
+
def blank?(value)
|
|
187
|
+
value.nil? || value.to_s.strip.empty?
|
|
188
|
+
end
|
|
189
|
+
private_class_method :blank?
|
|
190
|
+
|
|
191
|
+
def read_savepoint(intent_dir)
|
|
192
|
+
path = savepoint_path(intent_dir)
|
|
193
|
+
File.exist?(path) ? File.read(path) : ""
|
|
194
|
+
end
|
|
195
|
+
private_class_method :read_savepoint
|
|
196
|
+
|
|
197
|
+
def savepoint_path(intent_dir)
|
|
198
|
+
File.join(intent_dir.to_s, "savepoint.md")
|
|
199
|
+
end
|
|
200
|
+
private_class_method :savepoint_path
|
|
201
|
+
end
|