@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,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